diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..6da2510f6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +### What does this PR do? + +### Screenshot / video of UI + + + +### What issues does this PR fix or reference? + + + +### How to test this PR? + + \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..17a7fbc24 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# Set update schedule for GitHub Actions + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/build-next.yaml b/.github/workflows/build-next.yaml index 822b63287..5c22e389a 100644 --- a/.github/workflows/build-next.yaml +++ b/.github/workflows/build-next.yaml @@ -26,17 +26,17 @@ jobs: build: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "dir=$(npx yarn cache dir)" >> ${GITHUB_OUTPUT} - - uses: actions/cache@v3 + - uses: actions/cache@v4 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -57,10 +57,10 @@ jobs: - name: Publish Image id: publish-image run: | - IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/studio-extension - IMAGE_LATEST=${IMAGE_NAME}:latest + IMAGE_NAME=ghcr.io/${{ github.repository_owner }}/ai-studio + IMAGE_NIGHTLY=${IMAGE_NAME}:nightly IMAGE_SHA=${IMAGE_NAME}:${GITHUB_SHA} - podman build -t $IMAGE_LATEST . - podman push $IMAGE_LATEST - podman tag $IMAGE_LATEST $IMAGE_SHA + podman build -t $IMAGE_NIGHTLY . + podman push $IMAGE_NIGHTLY + podman tag $IMAGE_NIGHTLY $IMAGE_SHA podman push $IMAGE_SHA diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index 71f8f4ecc..af127f5c3 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: Get yarn cache directory path (Windows) if: ${{ matrix.os=='windows-2022' }} @@ -44,7 +44,7 @@ jobs: id: yarn-cache-dir-path-unix run: echo "dir=$(yarn cache dir)" >> ${GITHUB_OUTPUT} - - uses: actions/cache@v3 + - uses: actions/cache@v4 if: ${{ matrix.os=='windows-2022' }} id: yarn-cache-windows with: @@ -53,7 +53,7 @@ jobs: restore-keys: | ${{ runner.os }}-yarn- - - uses: actions/cache@v3 + - uses: actions/cache@v4 if: ${{ matrix.os=='ubuntu-22.04'}} id: yarn-cache-unix with: @@ -77,6 +77,9 @@ jobs: - name: Run typecheck run: yarn typecheck + - name: Run svelte check + run: yarn svelte:check + # Check we don't have changes in git - name: Check no changes in git if: ${{ matrix.os=='ubuntu-22.04'}} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..40793ac85 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,167 @@ +# +# Copyright (C) 2024 Red Hat, Inc. +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 + +name: release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release' + required: true + branch: + description: 'Branch to use for the release' + required: true + default: main +env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + +jobs: + + tag: + name: Tagging + runs-on: ubuntu-20.04 + outputs: + githubTag: ${{ steps.TAG_UTIL.outputs.githubTag}} + extVersion: ${{ steps.TAG_UTIL.outputs.extVersion}} + releaseId: ${{ steps.create_release.outputs.id}} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch }} + - name: Generate tag utilities + id: TAG_UTIL + run: | + TAG_PATTERN=${{ github.event.inputs.version }} + echo "githubTag=v$TAG_PATTERN" >> ${GITHUB_OUTPUT} + echo "extVersion=$TAG_PATTERN" >> ${GITHUB_OUTPUT} + + - name: tag + run: | + git config --local user.name ${{ github.actor }} + + # Add the new version in package.json file + sed -i "s#version\":\ \"\(.*\)\",#version\":\ \"${{ steps.TAG_UTIL.outputs.extVersion }}\",#g" package.json + sed -i "s#version\":\ \"\(.*\)\",#version\":\ \"${{ steps.TAG_UTIL.outputs.extVersion }}\",#g" packages/backend/package.json + sed -i "s#version\":\ \"\(.*\)\",#version\":\ \"${{ steps.TAG_UTIL.outputs.extVersion }}\",#g" packages/frontend/package.json + git add package.json + git add packages/backend/package.json + git add packages/frontend/package.json + + # commit the changes + git commit -m "chore: πŸ₯ tagging ${{ steps.TAG_UTIL.outputs.githubTag }} πŸ₯³" + echo "Tagging with ${{ steps.TAG_UTIL.outputs.githubTag }}" + git tag ${{ steps.TAG_UTIL.outputs.githubTag }} + git push origin ${{ steps.TAG_UTIL.outputs.githubTag }} + - name: Create Release + id: create_release + uses: ncipollo/release-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag: ${{ steps.TAG_UTIL.outputs.githubTag }} + name: ${{ steps.TAG_UTIL.outputs.githubTag }} + draft: true + prerelease: false + + - name: Create the PR to bump the version in the main branch (only if we're tagging from main branch) + if: ${{ github.event.inputs.branch == 'main' }} + run: | + git config --local user.name ${{ github.actor }} + CURRENT_VERSION=$(echo "${{ steps.TAG_UTIL.outputs.extVersion }}") + tmp=${CURRENT_VERSION%.*} + minor=${tmp#*.} + bumpedVersion=${CURRENT_VERSION%%.*}.$((minor + 1)).0 + bumpedBranchName="bump-to-${bumpedVersion}" + git checkout -b "${bumpedBranchName}" + sed -i "s#version\":\ \"\(.*\)\",#version\":\ \"${bumpedVersion}-next\",#g" package.json + sed -i "s#version\":\ \"\(.*\)\",#version\":\ \"${bumpedVersion}-next\",#g" packages/backend/package.json + sed -i "s#version\":\ \"\(.*\)\",#version\":\ \"${bumpedVersion}-next\",#g" packages/frontend/package.json + git add package.json + git add packages/backend/package.json + git add packages/frontend/package.json + git commit -s --amend -m "chore: bump version to ${bumpedVersion}" + git push origin "${bumpedBranchName}" + echo -e "πŸ“’ Bump version to ${bumpedVersion}\n\n${{ steps.TAG_UTIL.outputs.extVersion }} has been released.\n\n Time to switch to the new ${bumpedVersion} version πŸ₯³" > /tmp/pr-title + pullRequestUrl=$(gh pr create --title "chore: πŸ“’ Bump version to ${bumpedVersion}" --body-file /tmp/pr-title --head "${bumpedBranchName}" --base "main") + echo "πŸ“’ Pull request created: ${pullRequestUrl}" + echo "➑️ Flag the PR as being ready for review" + gh pr ready "${pullRequestUrl}" + echo "πŸ”… Mark the PR as being ok to be merged automatically" + gh pr merge "${pullRequestUrl}" --auto --rebase + git checkout ${{ steps.TAG_UTIL.outputs.githubTag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build: + needs: [tag] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.tag.outputs.githubTag }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(npx yarn cache dir)" >> ${GITHUB_OUTPUT} + + - uses: actions/cache@v4 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Execute yarn + if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} + run: npx yarn --frozen-lockfile --network-timeout 180000 + + - name: Run Build + run: npx yarn build + + - name: Login to ghcr.io + run: podman login --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} ghcr.io + + - name: Build Image + id: build-image + run: | + podman build -t ghcr.io/${{ github.repository_owner }}/ai-studio:${{ needs.tag.outputs.extVersion }} . + podman push ghcr.io/${{ github.repository_owner }}/ai-studio:${{ needs.tag.outputs.extVersion }} + podman tag ghcr.io/${{ github.repository_owner }}/ai-studio:${{ needs.tag.outputs.extVersion }} ghcr.io/${{ github.repository_owner }}/ai-studio:latest + podman push ghcr.io/${{ github.repository_owner }}/ai-studio:latest + + release: + needs: [tag, build] + name: Release + runs-on: ubuntu-20.04 + steps: + - name: id + run: echo the release id is ${{ needs.tag.outputs.releaseId}} + + - name: Publish release + uses: StuYarrow/publish-release@v1.1.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + id: ${{ needs.tag.outputs.releaseId}} + diff --git a/.gitignore b/.gitignore index 831018a8d..8cea9f0ac 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist .eslintcache **/coverage +.idea diff --git a/.prettierrc b/.prettierrc index 9f180e907..f237f8b06 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,8 +1,12 @@ { + "svelteSortOrder" : "options-styles-scripts-markup", + "svelteStrictMode": true, + "svelteAllowShorthand": false, + "svelteIndentScriptAndStyle": false, "bracketSameLine": true, "singleQuote": true, "arrowParens": "avoid", "printWidth": 120, - "trailingComma": "all" + "trailingComma": "all", + "plugins": ["prettier-plugin-svelte"] } - diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..dae109a4e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# Default Owners +* @projectatomic/ai-studio-reviewers diff --git a/Containerfile b/Containerfile index 12e53e6dc..3e4c9f1c3 100644 --- a/Containerfile +++ b/Containerfile @@ -26,9 +26,9 @@ COPY README.md /extension/ FROM scratch -LABEL org.opencontainers.image.title="Studio extension" \ - org.opencontainers.image.description="Studio extension" \ +LABEL org.opencontainers.image.title="AI Studio" \ + org.opencontainers.image.description="AI Studio" \ org.opencontainers.image.vendor="Red Hat" \ - io.podman-desktop.api.version=">= 1.6.0" + io.podman-desktop.api.version=">= 1.7.0" COPY --from=builder /extension /extension diff --git a/PACKAGING-GUIDE.md b/PACKAGING-GUIDE.md new file mode 100644 index 000000000..4a49fbeb1 --- /dev/null +++ b/PACKAGING-GUIDE.md @@ -0,0 +1,111 @@ +# Packaging guide + +## ApplicationCatalog + +AI Studio uses an internal catalog embedded within the application. This catalog is loaded +by AI Studio and displayed when you access the catalog page. + +The format of the catalog is JSON. It is possible for users to have a custom version of +the catalog. In order to do so, copy the file located at https://github.com/projectatomic/ai-studio/blob/main/packages/backend/src/ai.json +to $HOME/podman-desktop/ai-studio/catalog.json and AI Studio will use it instead of the embedded one. +Any change done to this file will also be automatically loaded by AI Studio. + +### Format of the catalog file + +The catalog file has three main elements: categories, models and recipes. Each of these elements is +represented in the JSON file as an array. + +#### Categories + +This is the top level construct of the catalog UI. Recipes are grouped into categories. A category +represents the kind of AI application. Although the list of categories provided by default by +AI Studio represents the AI landscape, it is possible to add new categories. + +A category has three main attributes: an id (which should be unique among categories), a description +and a name. The category id attribute will then be used to attach a recipe to one or several categories. + +#### Models + +The catalog also lists the models that may be associated to recipes. A model is also a first class +citizen in AI Studio as they will be listed in the Models page and can be tested through the playground. + +A model has the following attributes: +- ```id```: a unique identifier for the model +- ```name```: the model name +- ```description```: a detailed description about the model +- ```hw```: the hardware where the model is compatible. Possible values are CPU and GPU +- ```registry```: the model registry where the model is stored +- ```popularity```: an integer field giving the rating of the model. Can be thought as the number of stars +- ```license```: the license under which the model is available +- ```url```: the URL used to download the model + +#### Recipes + +A recipe is a sample AI application that is packaged as one or several containers. It is built by AI Studio when the user chooses to download and run it on their workstation. It is provided as +source code and AI Studio will make sure the container images are built prior to launching the containers. + +A recipe has the following attributes: +- ```id```: a unique identifier to the recipe +- ```name```: the recipe name +- ```description```: a detailed description about the recipe +- ```repository```: the URL where the recipe code can be retrieved +- ```categories```: an array of category id to be associated by this recipe +- ```config```: an optional path of the configuration file within the repository. If not provided, the file is assumed to be located at the root the repository and called ai-studio.yaml +- ```readme```: a markdown description of the recipe +- ```models```: an array of model id to be associated with this recipe + +#### Recipe configuration file + +The configuration file is called ```ai-studio.yaml``` and follows the following syntax. + +The root elements are called ```version``` and ```application```. + +```version``` represents the version of the specifications that ai-studio adheres to (so far, the only accepted value here is `v1.0`). + +```application``` contains an attribute called ```containers``` whose syntax is an array of objects containing the following attributes: +- ```name```: the name of the container +- ```contextdir```: the context directory used to build the container. +- ```containerfile```: the containerfile used to build the image +- ```model-service```: a boolean flag used to indicate if the container is running the model or not +- ```arch```: an optional array of architecture for which this image is compatible with. The values follow the +[GOARCH specification](https://go.dev/src/go/build/syslist.go) +- ```gpu-env```: an optional array of GPU environment for which this image is compatible with. The only accepted value here is cuda. +- ```ports```: an optional array of ports for which the application listens to. +- `image`: an optional image name to be used when building the container image. + +The container that is running the service (having the ```model-service``` flag equal to ```true```) can use at runtime +the model managed by AI Studio through an environment variable ```MODEL_PATH``` whose value is the full path name of the +model file. + +Below is given an example of such a configuration file: +```yaml +application: + containers: + - name: chatbot-inference-app + contextdir: ai_applications + containerfile: builds/Containerfile + - name: chatbot-model-service + contextdir: model_services + containerfile: base/Containerfile + model-service: true + arch: + - arm64 + - amd64 + ports: + - 8001 + image: quay.io/redhat-et/chatbot-model-service:latest + - name: chatbot-model-servicecuda + contextdir: model_services + containerfile: cuda/Containerfile + model-service: true + gpu-env: + - cuda + arch: + - amd64 + ports: + - 8501 + image: quay.io/redhat-et/model_services:latest +``` + + + diff --git a/README.md b/README.md index a2e48e30c..a514d1a6a 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,47 @@ -# studio extension +# AI studio -## Installing a development version +The Podman Desktop AI Studio extension simplifies getting started and developing with AI in a local environment. It provides key open-source technologies to start building on AI. A curated catalog of so-called recipes helps navigate the jungle of AI use cases and AI models. AI Studio further ships playgrounds: environments to experiment with and test AI models, for instance, a chat bot. -You can install this extension from Podman Desktop UI > βš™ Settings > Extensions > Install a new extension from OCI Image. +## Installation -The name of the image to use is `ghcr.io/projectatomic/studio-extension:latest`. +To install the extension, go to the Podman Desktop UI > βš™ Settings > Extensions > Install a new extension from OCI Image. -You can get earlier tags for the image at https://github.com/projectatomic/studio-extension/pkgs/container/studio-extension. +The name of the image to use is `ghcr.io/projectatomic/ai-studio`. You can get released tags for the image at https://github.com/projectatomic/studio-extension/pkgs/container/ai-studio. -These images contain development versions of the extension. There is no stable release yet. +To install a development version, use the `:nightly` tag as shown in the recording below. + +![](https://github.com/containers/podman-desktop-media/raw/ai-lab/gifs/installation.gif) ## Running in development mode +From the AI Studio packages/frontend folder: + +``` +$ yarn watch +``` + +If you are not live editing the frontend package, you can just run (from the AI Studio sources folder): + +``` +$ yarn build +``` + From the Podman Desktop sources folder: ``` $ yarn watch --extension-folder path-to-extension-sources-folder/packages/backend ``` +## Cleaning up resources + +We'll be adding a way in AI Lab to let a user cleanup the environment: see issue https://github.com/projectatomic/ai-studio/issues/469. +For the time being, please consider the following actions: +1. Remove the extension from Podman Desktop, from the Settings > Extensions +2. Remove the running playground environments from the list of Pods +3. Remove the images built by the recipes +4. Remove the containers related to AI +5. Cleanup your local clone of the recipes: `$HOME/podman-desktop/ai-studio` + ## Providing a custom catalog The extension provides a default catalog, but you can build your own catalog by creating a file `$HOME/podman-desktop/ai-studio/catalog.json`. @@ -28,6 +52,11 @@ Each recipe can belong to one or several categories. Each model can be used by o The format of the catalog is not stable nor versioned yet, you can see the current catalog's format [in the sources of the extension](https://github.com/projectatomic/studio-extension/blob/main/packages/backend/src/ai.json). +## Packaging sample applications + +Sample applications may be added to the catalog. See [packaging guide](PACKAGING-GUIDE.md) for detailed information. + + ## Feedback You can provide your feedback on the extension with [this form](https://forms.gle/tctQ4RtZSiMyQr3R8) or create [an issue on this repository](https://github.com/projectatomic/studio-extension/issues). diff --git a/USAGE_DATA.md b/USAGE_DATA.md new file mode 100644 index 000000000..e987a323e --- /dev/null +++ b/USAGE_DATA.md @@ -0,0 +1,26 @@ +# Data Collection + +The AI Studio extension uses telemetry to collect anonymous usage data in order to identify issues and improve our user experience. You can read our privacy statement +[here](https://developers.redhat.com/article/tool-data-collection). + +Telemetry for the extension is based on the Podman Desktop telemetry. + +Users are prompted during Podman Desktop first startup to accept or decline telemetry. This setting can be +changed at any time in Settings > Preferences > Telemetry. + +On disk the setting is stored in the `"telemetry.*"` keys within the settings file, +at `$HOME/.local/share/containers/podman-desktop/configuration/settings.json`. A generated anonymous id +is stored at `$HOME/.redhat/anonymousId`. + +## What's included in the telemetry data + +- General information, including operating system, machine architecture, and country. +- When the extension starts and stops. +- When the icon to enter the extension zone is clicked. +- When a recipe page is opened (with recipe Id and name). +- When a sample application is pulled (with recipe Id and name). +- When a playground is started or stopped (with model Id). +- When a request is sent to a model in the playground (with model Id, **without** request content). +- When a model is downloaded or deleted from disk. + +No personally identifiable information is captured. An anonymous id is used so that we can correlate the actions of a user even if we can't tell who they are. diff --git a/docs/proposals/ai-studio.md b/docs/proposals/ai-studio.md new file mode 100644 index 000000000..60b2576e7 --- /dev/null +++ b/docs/proposals/ai-studio.md @@ -0,0 +1,100 @@ +# Motivation + +Today, there is no notion of ordering between the containers. But we know that we have a dependency between +the client application and the container that is running the model. + +The second issue is that there is no concept of starting point for a container so today we rely only on the +container being started by the container engine and we know that this is not adequate for the model service container + +So this is handle by a kind of dirty fix: the containers are all started in parallel but as the client application +will fail because the model service is started (as it take a while), so we are trying to restart the client application +until the model service is properly started. + +The purpose of this change is to propose an update to the ai-studio.yaml so that it is as much generic as it +could be and inspired from the Compose specification. + +## Proposed changes + +Define a condition for the container to be properly started: this would be based on the readinessProbe that can already +be defined in a Kubernetes container. In the first iteration, we would support only the ```exec``` field. If +```readinessProbe``` is defined, then we would check for the healthcheck status field to be ```healthy``` + +So the current chatbot file would be updated from: + +```yaml +application: + type: language + name: chatbot + description: This is a LLM chatbot application that can interact with a llamacpp model-service + containers: + - name: chatbot-inference-app + contextdir: ai_applications + containerfile: builds/Containerfile + - name: chatbot-model-service + contextdir: model_services + containerfile: base/Containerfile + model-service: true + backend: + - llama + arch: + - arm64 + - amd64 + - name: chatbot-model-servicecuda + contextdir: model_services + containerfile: cuda/Containerfile + model-service: true + backend: + - llama + gpu-env: + - cuda + arch: + - amd64 +``` + +to + +```yaml +application: + type: language + name: chatbot + description: This is a LLM chatbot application that can interact with a llamacpp model-service + containers: + - name: chatbot-inference-app + contextdir: ai_applications + containerfile: builds/Containerfile + readinessProbe: # added + exec: # added + command: # added + - curl -f localhost:8080 || exit 1 # added + - name: chatbot-model-service + contextdir: model_services + containerfile: base/Containerfile + model-service: true + readinessProbe: # added + exec: # added + command: # added + - curl -f localhost:7860 || exit 1 # added + backend: + - llama + arch: + - arm64 + - amd64 + - name: chatbot-model-service + contextdir: model_services + containerfile: cuda/Containerfile + model-service: true + readinessProbe: # added + exec: # added + command: # added + - curl -f localhost:7860 || exit 1 # added + backend: + - llama + gpu-env: + - cuda + arch: + - amd64 +``` + +From the Podman Desktop API point of view, this would require extending the +[ContainerCreateOptions](https://podman-desktop.io/api/interfaces/ContainerCreateOptions) structure to support the +HealthCheck option. diff --git a/package.json b/package.json index e648818e6..39f937196 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,22 @@ { - "name": "studio-extension-monorepo", - "displayName": "studio-extension-monorepo", - "description": "studio-extension-monorepo", + "name": "ai-studio-monorepo", + "displayName": "ai-studio-monorepo", + "description": "ai-studio-monorepo", "publisher": "redhat", - "version": "0.0.0", + "version": "0.3.0-next", "private": true, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=20.9.0", + "npm": ">=10.2.3" }, "scripts": { "build": "concurrently \"yarn --cwd packages/frontend build\" \"yarn --cwd packages/backend build\"", "watch": "concurrently \"yarn --cwd packages/frontend watch\" \"yarn --cwd packages/backend watch\"", - "format:check": "prettier --check \"**/src/**/*.ts\"", - "format:fix": "prettier --write \"**/src/**/*.ts\"", + "format:check": "prettier --check \"**/src/**/*.{ts,svelte}\"", + "format:fix": "prettier --write \"**/src/**/*.{ts,svelte}\"", "lint:check": "eslint . --ext js,ts,tsx", "lint:fix": "eslint . --fix --ext js,ts,tsx", + "svelte:check": "svelte-check", "test:backend": "vitest run -r packages/backend --passWithNoTests --coverage", "test:frontend": "vitest run -r packages/frontend --passWithNoTests --coverage", "test:shared": "vitest run -r packages/shared --passWithNoTests --coverage", @@ -26,23 +27,25 @@ "typecheck": "npm run typecheck:shared && npm run typecheck:frontend && npm run typecheck:backend" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "^6.16.0", - "@typescript-eslint/parser": "^6.16.0", - "@vitest/coverage-v8": "^1.1.0", - "autoprefixer": "^10.4.16", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^6.21.0", + "@vitest/coverage-v8": "^1.4.0", + "autoprefixer": "^10.4.19", "concurrently": "^8.2.2", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "eslint-import-resolver-custom-alias": "^1.3.2", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-etc": "^2.0.3", "eslint-plugin-import": "^2.29.1", "eslint-plugin-no-null": "^1.0.2", "eslint-plugin-redundant-undefined": "^1.0.0", - "eslint-plugin-sonarjs": "^0.23.0", - "prettier": "^3.1.1", - "typescript": "5.3.3", - "vite": "^5.0.10", - "vitest": "^1.1.0" + "eslint-plugin-sonarjs": "^0.24.0", + "prettier": "^3.2.5", + "prettier-plugin-svelte": "^3.2.2", + "svelte-check": "^3.6.8", + "typescript": "5.4.3", + "vite": "^5.2.3", + "vitest": "^1.4.0" }, "workspaces": { "packages": [ @@ -51,6 +54,6 @@ }, "dependencies": { "js-yaml": "^4.1.0", - "simple-git": "^3.22.0" + "simple-git": "^3.23.0" } } diff --git a/packages/backend/package.json b/packages/backend/package.json index 50a3fe239..db00819e4 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,8 +1,8 @@ { - "name": "studio-extension", - "displayName": "studio extension", - "description": "Podman Desktop Studio Extension", - "version": "0.1.0-next", + "name": "ai-studio", + "displayName": "AI Studio", + "description": "Podman Desktop AI Studio", + "version": "0.3.0-next", "icon": "icon.png", "publisher": "redhat", "license": "Apache-2.0", @@ -23,7 +23,13 @@ "views": { "icons/containersList": [ { - "when": "ia-studio-model in containerLabelKeys", + "when": "ai-studio-model-id in containerLabelKeys", + "icon": "${brain-icon}" + } + ], + "icons/image": [ + { + "when": "ai-studio-recipe-id in imageLabelKeys", "icon": "${brain-icon}" } ] @@ -38,12 +44,18 @@ "watch": "vite --mode development build -w" }, "dependencies": { - "simple-git": "^3.22.0" + "mustache": "^4.2.0", + "openai": "^4.29.2", + "postman-code-generators": "^1.9.0", + "postman-collection": "^4.4.0", + "simple-git": "^3.23.0", + "xml-js": "^1.6.11" }, "devDependencies": { - "@podman-desktop/api": "1.7.0", + "@podman-desktop/api": "^1.8.0", "@types/js-yaml": "^4.0.9", - "@types/node": "^18", - "vitest": "^1.1.0" + "@types/node": "^20", + "@types/postman-collection": "^3.5.10", + "vitest": "^1.4.0" } } diff --git a/packages/backend/src/ai.json b/packages/backend/src/ai.json deleted file mode 100644 index 2ed682e49..000000000 --- a/packages/backend/src/ai.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "recipes": [ - { - "id": "chatbot", - "description" : "Chat bot application", - "name" : "ChatBot", - "repository": "https://github.com/axel7083/locallm", - "icon": "natural-language-processing", - "categories": [ - "natural-language-processing" - ], - "config": "chatbot/ai-studio.yaml", - "readme": "# Locallm\n\nThis repo contains artifacts that can be used to build and run LLM (Large Language Model) services locally on your Mac using podman. These containerized LLM services can be used to help developers quickly prototype new LLM based applications, without the need for relying on any other externally hosted services. Since they are already containerized, it also helps developers move from their prototype to production quicker. \n\n## Current Locallm Services: \n\n* [Chatbot](#chatbot)\n* [Text Summarization](#text-summarization)\n* [Fine-tuning](#fine-tuning)\n\n### Chatbot\n\nA simple chatbot using the gradio UI. Learn how to build and run this model service here: [Chatbot](/chatbot/).\n\n### Text Summarization\n\nAn LLM app that can summarize arbitrarily long text inputs. Learn how to build and run this model service here: [Text Summarization](/summarizer/).\n\n### Fine Tuning \n\nThis application allows a user to select a model and a data set they'd like to fine-tune that model on. Once the application finishes, it outputs a new fine-tuned model for the user to apply to other LLM services. Learn how to build and run this model training job here: [Fine-tuning](/finetune/).\n\n## Architecture\n![](https://raw.githubusercontent.com/MichaelClifford/locallm/main/assets/arch.jpg)\n\nThe diagram above indicates the general architecture for each of the individual model services contained in this repo. The core code available here is the \"LLM Task Service\" and the \"API Server\", bundled together under `model_services`. With an appropriately chosen model downloaded onto your host,`model_services/builds` contains the Containerfiles required to build an ARM or an x86 (with CUDA) image depending on your need. These model services are intended to be light-weight and run with smaller hardware footprints (given the Locallm name), but they can be run on any hardware that supports containers and scaled up if needed.\n\nWe also provide demo \"AI Applications\" under `ai_applications` for each model service to provide an example of how a developers could interact with the model service for their own needs. ", - "models": [ - "llama-2-7b-chat.Q5_K_S", - "albedobase-xl-1.3", - "sdxl-turbo" - ] - } - ], - "models": [ - { - "id": "llama-2-7b-chat.Q5_K_S", - "name": "Llama-2-7B-Chat-GGUF", - "description": "Llama 2 is a family of state-of-the-art open-access large language models released by Meta today, and we’re excited to fully support the launch with comprehensive integration in Hugging Face. Llama 2 is being released with a very permissive community license and is available for commercial use. The code, pretrained models, and fine-tuned models are all being released today πŸ”₯", - "hw": "CPU", - "registry": "Hugging Face", - "popularity": 3, - "license": "?", - "url": "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf" - }, - { - "id": "albedobase-xl-1.3", - "name": "AlbedoBase XL 1.3", - "description": "Stable Diffusion XL has 6.6 billion parameters, which is about 6.6 times more than the SD v1.5 version. I believe that this is not just a number, but a number that can lead to a significant improvement in performance. It has been a while since we realized that the overall performance of SD v1.5 has improved beyond imagination thanks to the explosive contributions of our community. Therefore, I am working on completing this AlbedoBase XL model in order to optimally reproduce the performance improvement that occurred in v1.5 in this XL version as well. My goal is to directly test the performance of all Checkpoints and LoRAs that are publicly uploaded to Civitai, and merge only the resources that are judged to be optimal after passing through several filters. This will surpass the performance of image-generating AI of companies such as Midjourney. As of now, AlbedoBase XL v0.4 has merged exactly 55 selected checkpoints and 138 LoRAs.", - "hw": "CPU", - "registry": "Civital", - "popularity": 3, - "license": "openrail++", - "url": "" - }, - { - "id": "sdxl-turbo", - "name": "SDXL Turbo", - "description": "SDXL Turbo achieves state-of-the-art performance with a new distillation technology, enabling single-step image generation with unprecedented quality, reducing the required step count from 50 to just one.", - "hw": "CPU", - "registry": "Hugging Face", - "popularity": 3, - "license": "sai-c-community", - "url": "" - } - ], - "categories": [ - { - "id": "natural-language-processing", - "name": "Natural Language Processing", - "description" : "Models that work with text: classify, summarize, translate, or generate text." - }, - { - "id": "computer-vision", - "description" : "Process images, from classification to object detection and segmentation.", - "name" : "Computer Vision" - }, - { - "id": "audio", - "description" : "Recognize speech or classify audio with audio models.", - "name" : "Audio" - }, - { - "id": "multimodal", - "description" : "Stuff about multimodal models goes here omg yes amazing.", - "name" : "Multimodal" - } - ] -} diff --git a/packages/backend/src/assets/ai.json b/packages/backend/src/assets/ai.json new file mode 100644 index 000000000..c10a00819 --- /dev/null +++ b/packages/backend/src/assets/ai.json @@ -0,0 +1,201 @@ +{ + "recipes": [ + { + "id": "chatbot", + "description" : "This is a Streamlit chat demo application.", + "name" : "ChatBot", + "repository": "https://github.com/redhat-et/locallm", + "ref": "6795ba1", + "icon": "natural-language-processing", + "categories": [ + "natural-language-processing" + ], + "config": "chatbot-langchain/ai-studio.yaml", + "readme": "# Chat Application\n\nThis model service is intended be used as the basis for a chat application. It is capable of having arbitrarily long conversations\nwith users and retains a history of the conversation until it reaches the maximum context length of the model.\nAt that point, the service will remove the earliest portions of the conversation from its memory.\n\nTo use this model service, please follow the steps below:\n\n* [Download Model](#download-models)\n* [Build Image](#build-the-image)\n* [Run Image](#run-the-image)\n* [Interact with Service](#interact-with-the-app)\n* [Deploy on Openshift](#deploy-on-openshift)\n\n## Build and Deploy Locally\n\n### Download model(s)\n\nThe two models that we have tested and recommend for this example are Llama2 and Mistral. The locations of the GGUF variants\nare listed below:\n\n* Llama2 - https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/tree/main\n* Mistral - https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/tree/main\n\n_For a full list of supported model variants, please see the \"Supported models\" section of the\n[llama.cpp repository](https://github.com/ggerganov/llama.cpp?tab=readme-ov-file#description)._\n\nThis example assumes that the developer already has a copy of the model that they would like to use downloaded onto their host machine and located in the `/models` directory of this repo. \n\nThis can be accomplished with:\n\n```bash\ncd models\nwget https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf\ncd ../\n```\n\n## Deploy from Local Container\n\n### Build the image\n\nBuild the `model-service` image.\n\n```bash\ncd chatbot/model_services\npodman build -t chatbot:service -f base/Containerfile .\n```\n\nAfter the image is created it should be run with the model mounted as volume, as shown below.\nThis prevents large model files from being loaded into the container image which can cause a significant slowdown\nwhen transporting the images. If it is required that a model-service image contains the model,\nthe Containerfiles can be modified to copy the model into the image.\n\nWith the model-service image, in addition to a volume mounted model file, an environment variable, $MODEL_PATH,\nshould be set at runtime. If not set, the default location where the service expects a model is at \n`/locallm/models/llama-2-7b-chat.Q5_K_S.gguf` inside the running container. This file can be downloaded from the URL\n`https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf`.\n\n### Run the image\n\nOnce the model service image is built, it can be run with the following:\nBy assuming that we want to mount the model `llama-2-7b-chat.Q5_K_S.gguf`\n\n```bash\nexport MODEL_FILE=llama-2-7b-chat.Q5_K_S.gguf\npodman run --rm -d -it \\n -v /local/path/to/$MODEL_FILE:/locallm/models/$MODEL_FILE:Z \\n --env MODEL_PATH=/locallm/models/$MODEL_FILE \\n -p 7860:7860 \\n chatbot:service\n```\n\n### Interact with the app\n\nNow the service can be interacted with by going to `0.0.0.0:7860` in your browser.\n\n![](https://raw.githubusercontent.com/redhat-et/locallm/main/assets/app.png)\n\n\nYou can also use the example [chatbot/ai_applications/ask.py](ask.py) to interact with the model-service in a terminal.\nIf the `--prompt` argument is left blank, it will default to \"Hello\".\n\n```bash\ncd chatbot/ai_applications\n\npython ask.py --prompt \n```\n\nOr, you can build the `ask.py` into a container image and run it alongside the model-service container, like so:\n\n```bash\ncd chatbot/ai_applications\npodman build -t chatbot -f builds/Containerfile .\npodman run --rm -d -it -p 8080:8080 chatbot # then interact with the application at 0.0.0.0:8080 in your browser\n```\n\n## Deploy on Openshift\n\nNow that we've developed an application locally that leverages an LLM, we'll want to share it with a wider audience.\nLet's get it off our machine and run it on OpenShift.\n\n### Rebuild for x86\n\nIf you are on a Mac, you'll need to rebuild the model-service image for the x86 architecture for most use case outside of Mac.\nSince this is an AI workload, you may also want to take advantage of Nvidia GPU's available outside our local machine.\nIf so, build the model-service with a base image that contains CUDA and builds llama.cpp specifically for a CUDA environment.\n\n```bash\ncd chatbot/model_services/cuda\npodman build --platform linux/amd64 -t chatbot:service-cuda -f cuda/Containerfile .\n```\n\nThe CUDA environment significantly increases the size of the container image.\nIf you are not utilizing a GPU to run this application, you can create an image\nwithout the CUDA layers for an x86 architecture machine with the following:\n\n```bash\ncd chatbot/model_services\npodman build --platform linux/amd64 -t chatbot:service-amd64 -f base/Containerfile .\n```\n\n### Push to Quay\n\nOnce you login to [quay.io](quay.io) you can push your own newly built version of this LLM application to your repository\nfor use by others.\n\n```bash\npodman login quay.io\n```\n\n```bash\npodman push localhost/chatbot:service-amd64 quay.io//\n```\n\n### Deploy\n\nNow that your model lives in a remote repository we can deploy it.\nGo to your OpenShift developer dashboard and select \"+Add\" to use the Openshift UI to deploy the application.\n\n![](https://raw.githubusercontent.com/redhat-et/locallm/main/assets/add_image.png)\n\nSelect \"Container images\"\n\n![](https://raw.githubusercontent.com/redhat-et/locallm/main/assets/container_images.png)\n\nThen fill out the form on the Deploy page with your [quay.io](quay.io) image name and make sure to set the \"Target port\" to 7860.\n\n![](https://raw.githubusercontent.com/redhat-et/locallm/main/assets/deploy.png)\n\nHit \"Create\" at the bottom and watch your application start.\n\nOnce the pods are up and the application is working, navigate to the \"Routes\" section and click on the link created for you\nto interact with your app.\n\n![](https://raw.githubusercontent.com/redhat-et/locallm/main/assets/app.png)", + "models": [ + "hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M", + "hf.NousResearch.Hermes-2-Pro-Mistral-7B.Q4_K_M", + "hf.ibm.merlinite-7b-Q4_K_M", + "hf.froggeric.Cerebrum-1.0-7b-Q4_KS", + "hf.TheBloke.openchat-3.5-0106.Q4_K_M", + "hf.TheBloke.mistral-7b-openorca.Q4_K_M", + "hf.MaziyarPanahi.phi-2.Q4_K_M", + "hf.llmware.dragon-mistral-7b-q4_k_m", + "hf.MaziyarPanahi.MixTAO-7Bx2-MoE-Instruct-v7.0.Q4_K_M" + ] + }, + { + "id": "summarizer", + "description" : "This is a Streamlit demo application for summarizing text.", + "name" : "Summarizer", + "repository": "https://github.com/redhat-et/locallm", + "ref": "10bc46e", + "icon": "natural-language-processing", + "categories": [ + "natural-language-processing" + ], + "config": "summarizer-langchain/ai-studio.yaml", + "readme": "# Summarizer\n\nThis model service is intended be be used for text summarization tasks. This service can ingest an arbitrarily long text input. If the input length is less than the models maximum context window it will summarize the input directly. If the input is longer than the maximum context window, the input will be divided into appropriately sized chunks. Each chunk will be summarized and a final \"summary of summaries\" will be the services final output. ", + "models": [ + "hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M", + "hf.NousResearch.Hermes-2-Pro-Mistral-7B.Q4_K_M", + "hf.ibm.merlinite-7b-Q4_K_M", + "hf.froggeric.Cerebrum-1.0-7b-Q4_KS", + "hf.TheBloke.openchat-3.5-0106.Q4_K_M", + "hf.TheBloke.mistral-7b-openorca.Q4_K_M", + "hf.MaziyarPanahi.phi-2.Q4_K_M", + "hf.llmware.dragon-mistral-7b-q4_k_m", + "hf.MaziyarPanahi.MixTAO-7Bx2-MoE-Instruct-v7.0.Q4_K_M" + ] + }, + { + "id": "codegeneration", + "description" : "This is a code-generation demo application.", + "name" : "Code Generation", + "repository": "https://github.com/redhat-et/locallm", + "ref": "a5e830d", + "icon": "generator", + "categories": [ + "natural-language-processing" + ], + "config": "code-generation/ai-studio.yaml", + "readme": "# Code Generation\n\nThis example will deploy a local code-gen application using a llama.cpp model server and a python app built with langchain. \n\n### Download Model\n\n- **codellama**\n\n - Download URL: `wget https://huggingface.co/TheBloke/CodeLlama-7B-Instruct-GGUF/resolve/main/codellama-7b-instruct.Q4_K_M.gguf` \n\n```\n\ncd ../models\n\nwget \n\ncd ../\n\n```\n\n### Deploy Model Service\n\nTo start the model service, refer to [the playground model-service document](../playground/README.md). Deploy the LLM server and volumn mount the model of choice.\n\n```\n\npodman run --rm -it -d \\ \n\n -p 8001:8001 \\ \n\n -v Local/path/to/locallm/models:/locallm/models:ro,Z \\ \n\n -e MODEL_PATH=models/ \\ \n\n -e HOST=0.0.0.0 \\ \n\n -e PORT=8001 \\ \n\n playground:image\n\n```\n\n### Build Container Image\n\nOnce the model service is deployed, then follow the instruction below to build your container image and run it locally. \n\n- `podman build -t codegen-app code-generation -f code-generation/builds/Containerfile`\n\n- `podman run -it -p 8501:8501 codegen-app -- -m http://10.88.0.1:8001/v1` ", + "models": [ + "hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M", + "hf.NousResearch.Hermes-2-Pro-Mistral-7B.Q4_K_M", + "hf.ibm.merlinite-7b-Q4_K_M", + "hf.TheBloke.mistral-7b-codealpaca-lora.Q4_K_M", + "hf.TheBloke.mistral-7b-code-16k-qlora.Q4_K_M", + "hf.froggeric.Cerebrum-1.0-7b-Q4_KS", + "hf.TheBloke.openchat-3.5-0106.Q4_K_M", + "hf.TheBloke.mistral-7b-openorca.Q4_K_M", + "hf.MaziyarPanahi.phi-2.Q4_K_M", + "hf.llmware.dragon-mistral-7b-q4_k_m", + "hf.MaziyarPanahi.MixTAO-7Bx2-MoE-Instruct-v7.0.Q4_K_M" + ] + } + ], + "models": [ + { + "id": "hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M", + "name": "TheBloke/Mistral-7B-Instruct-v0.1-GGUF", + "description": "The Mistral-7B-Instruct-v0.1 Large Language Model (LLM) is a instruct fine-tuned version of the [Mistral-7B-v0.1](https://huggingface.co/mistralai/Mistral-7B-v0.1) generative text model using a variety of publicly available conversation datasets. For full details of this model please read our [release blog post](https://mistral.ai/news/announcing-mistral-7b/)", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf" + }, + { + "id": "hf.NousResearch.Hermes-2-Pro-Mistral-7B.Q4_K_M", + "name": "NousResearch/Hermes-2-Pro-Mistral-7B-GGUF", + "description": "This is the GGUF version of the model, made for the llama.cpp inference engine.\n If you are looking for the transformers/fp16 model, it is available here: [https://huggingface.co/NousResearch/Hermes-2-Pro-Mistral-7B](https://huggingface.co/NousResearch/Hermes-2-Pro-Mistral-7B)\n Hermes 2 Pro on Mistral 7B is the new flagship 7B Hermes!\n Hermes 2 Pro is an upgraded, retrained version of Nous Hermes 2, consisting of an updated and cleaned version of the OpenHermes 2.5 Dataset, as well as a newly introduced Function Calling and JSON Mode dataset developed in-house.\n This new version of Hermes maintains its excellent general task and conversation capabilities - but also excels at Function Calling, JSON Structured Outputs, and has improved on several other metrics as well, scoring a 90% on our function calling evaluation built in partnership with Fireworks.AI, and an 81% on our structured JSON Output evaluation.\n Hermes Pro takes advantage of a special system prompt and multi-turn function calling structure with a new chatml role in order to make function calling reliable and easy to parse. Learn more about prompting below.\nThis work was a collaboration between Nous Research, @interstellarninja, and Fireworks.AI\n Learn more about the function calling on our github repo here: [https://github.com/NousResearch/Hermes-Function-Calling/tree/main](https://github.com/NousResearch/Hermes-Function-Calling/tree/main)", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/NousResearch/Hermes-2-Pro-Mistral-7B-GGUF/resolve/main/Hermes-2-Pro-Mistral-7B.Q4_K_M.gguf" + }, + { + "id": "hf.ibm.merlinite-7b-Q4_K_M", + "name": "ibm/merlinite-7b-GGUF", + "description": "## Merlinite 7b - GGUF\n4-bit quantized version of [ibm/merlinite-7b](https://huggingface.co/ibm/merlinite-7b)", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/ibm/merlinite-7b-GGUF/resolve/main/merlinite-7b-Q4_K_M.gguf" + }, + { + "id": "hf.TheBloke.mistral-7b-codealpaca-lora.Q4_K_M", + "name": "TheBloke/Mistral-7B-codealpaca-lora-GGUF", + "description": "## Mistral 7B CodeAlpaca Lora - GGUF\n- Model creator: [Kamil](https://huggingface.co/Nondzu)\n- Original model: [Mistral 7B CodeAlpaca Lora](https://huggingface.co/Nondzu/Mistral-7B-codealpaca-lora)\n### Description\nThis repo contains GGUF format model files for [Kamil's Mistral 7B CodeAlpaca Lora](https://huggingface.co/Nondzu/Mistral-7B-codealpaca-lora).\nThese files were quantised using hardware kindly provided by [Massed Compute](https://massedcompute.com/).", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/TheBloke/Mistral-7B-codealpaca-lora-GGUF/resolve/main/mistral-7b-codealpaca-lora.Q4_K_M.gguf" + }, + { + "id": "hf.TheBloke.mistral-7b-code-16k-qlora.Q4_K_M", + "name": "TheBloke/Mistral-7B-Code-16K-qlora-GGUF", + "description": "## Mistral 7B Code 16K qLoRA - GGUF\n- Model creator: [Kamil](https://huggingface.co/Nondzu)\n- Original model: [Mistral 7B Code 16K qLoRA](https://huggingface.co/Nondzu/Mistral-7B-code-16k-qlora)\n## Description\nThis repo contains GGUF format model files for [Kamil's Mistral 7B Code 16K qLoRA](https://huggingface.co/Nondzu/Mistral-7B-code-16k-qlora).", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/TheBloke/Mistral-7B-Code-16K-qlora-GGUF/resolve/main/mistral-7b-code-16k-qlora.Q4_K_M.gguf" + }, + { + "id": "hf.froggeric.Cerebrum-1.0-7b-Q4_KS", + "name": "froggeric/Cerebrum-1.0-7b-GGUF", + "description": "GGUF quantisations of [AetherResearch/Cerebrum-1.0-7b](https://huggingface.co/AetherResearch/Cerebrum-1.0-7b)\n## Introduction\nCerebrum 7b is a large language model (LLM) created specifically for reasoning tasks. It is based on the Mistral 7b model, fine-tuned on a small custom dataset of native chain of thought data and further improved with targeted RLHF (tRLHF), a novel technique for sample-efficient LLM alignment. Unlike numerous other recent fine-tuning approaches, our training pipeline includes under 5000 training prompts and even fewer labeled datapoints for tRLHF.\nNative chain of thought approach means that Cerebrum is trained to devise a tactical plan before tackling problems that require thinking. For brainstorming, knowledge intensive, and creative tasks Cerebrum will typically omit unnecessarily verbose considerations.\nZero-shot prompted Cerebrum significantly outperforms few-shot prompted Mistral 7b as well as much larger models (such as Llama 2 70b) on a range of tasks that require reasoning, including ARC Challenge, GSM8k, and Math.\nThis LLM model works a lot better than any other mistral mixtral models for agent data, tested on 14th March 2024.", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/froggeric/Cerebrum-1.0-7b-GGUF/resolve/main/Cerebrum-1.0-7b-Q4_KS.gguf" + }, + { + "id": "hf.TheBloke.openchat-3.5-0106.Q4_K_M", + "name": "TheBloke/openchat-3.5-0106-GGUF", + "description": "## Openchat 3.5 0106 - GGUF\n- Model creator: [OpenChat](https://huggingface.co/openchat)\n- Original model: [Openchat 3.5 0106](https://huggingface.co/openchat/openchat-3.5-0106)\n## DescriptionThis repo contains GGUF format model files for [OpenChat's Openchat 3.5 0106](https://huggingface.co/openchat/openchat-3.5-0106).\nThese files were quantised using hardware kindly provided by [Massed Compute](https://massedcompute.com/).", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/TheBloke/openchat-3.5-0106-GGUF/resolve/main/openchat-3.5-0106.Q4_K_M.gguf" + }, + { + "id": "hf.TheBloke.mistral-7b-openorca.Q4_K_M", + "name": "TheBloke/Mistral-7B-OpenOrca-GGUF", + "description": "## Mistral 7B OpenOrca - GGUF- Model creator: [OpenOrca](https://huggingface.co/Open-Orca)\n- Original model: [Mistral 7B OpenOrca](https://huggingface.co/Open-Orca/Mistral-7B-OpenOrca)\n## Description\nThis repo contains GGUF format model files for [OpenOrca's Mistral 7B OpenOrca](https://huggingface.co/Open-Orca/Mistral-7B-OpenOrca).", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/TheBloke/Mistral-7B-OpenOrca-GGUF/resolve/main/mistral-7b-openorca.Q4_K_M.gguf" + }, + { + "id": "hf.MaziyarPanahi.phi-2.Q4_K_M", + "name": "MaziyarPanahi/phi-2-GGUF", + "description": "## [MaziyarPanahi/phi-2-GGUF](https://huggingface.co/MaziyarPanahi/phi-2-GGUF)\n- Model creator: [microsoft](https://huggingface.co/microsoft)\n- Original model: [microsoft/phi-2](https://huggingface.co/microsoft/phi-2)\n## Description\n[MaziyarPanahi/phi-2-GGUF](https://huggingface.co/MaziyarPanahi/phi-2-GGUF) contains GGUF format model files for [microsoft/phi-2](https://huggingface.co/microsoft/phi-2).", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/MaziyarPanahi/phi-2-GGUF/resolve/main/phi-2.Q4_K_M.gguf" + }, + { + "id": "hf.llmware.dragon-mistral-7b-q4_k_m", + "name": "llmware/dragon-mistral-7b-v0", + "description": "## Model Card for Model ID\ndragon-mistral-7b-v0 part of the dRAGon ('Delivering RAG On ...') model series, RAG-instruct trained on top of a Mistral-7B base model.\nDRAGON models have been fine-tuned with the specific objective of fact-based question-answering over complex business and legal documents with an emphasis on reducing hallucinations and providing short, clear answers for workflow automation.", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/llmware/dragon-mistral-7b-v0/resolve/main/dragon-mistral-7b-q4_k_m.gguf" + }, + { + "id": "hf.MaziyarPanahi.MixTAO-7Bx2-MoE-Instruct-v7.0.Q4_K_M", + "name": "MaziyarPanahi/MixTAO-7Bx2-MoE-Instruct-v7.0-GGUF", + "description": "## [MaziyarPanahi/MixTAO-7Bx2-MoE-Instruct-v7.0-GGUF](https://huggingface.co/MaziyarPanahi/MixTAO-7Bx2-MoE-Instruct-v7.0-GGUF)\n- Model creator: [zhengr](https://huggingface.co/zhengr)\n- Original model: [zhengr/MixTAO-7Bx2-MoE-Instruct-v7.0](https://huggingface.co/zhengr/MixTAO-7Bx2-MoE-Instruct-v7.0)\n## Description\n[MaziyarPanahi/MixTAO-7Bx2-MoE-Instruct-v7.0-GGUF](https://huggingface.co/MaziyarPanahi/MixTAO-7Bx2-MoE-Instruct-v7.0-GGUF) contains GGUF format model files for [zhengr/MixTAO-7Bx2-MoE-Instruct-v7.0](https://huggingface.co/zhengr/MixTAO-7Bx2-MoE-Instruct-v7.0).", + "hw": "CPU", + "registry": "Hugging Face", + "license": "Apache-2.0", + "url": "https://huggingface.co/MaziyarPanahi/MixTAO-7Bx2-MoE-Instruct-v7.0-GGUF/resolve/main/MixTAO-7Bx2-MoE-Instruct-v7.0.Q4_K_M.gguf" + } + ], + "categories": [ + { + "id": "natural-language-processing", + "name": "Natural Language Processing", + "description" : "Models that work with text: classify, summarize, translate, or generate text." + }, + { + "id": "computer-vision", + "description" : "Process images, from classification to object detection and segmentation.", + "name" : "Computer Vision" + }, + { + "id": "audio", + "description" : "Recognize speech or classify audio with audio models.", + "name" : "Audio" + }, + { + "id": "multimodal", + "description" : "Stuff about multimodal models goes here omg yes amazing.", + "name" : "Multimodal" + } + ] +} diff --git a/packages/backend/src/extension.spec.ts b/packages/backend/src/extension.spec.ts index dafd8f8a1..e2acd881a 100644 --- a/packages/backend/src/extension.spec.ts +++ b/packages/backend/src/extension.spec.ts @@ -16,50 +16,46 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { beforeEach, expect, test, vi } from 'vitest'; -import type * as podmanDesktopApi from '@podman-desktop/api'; +import type { ExtensionContext } from '@podman-desktop/api'; import { activate, deactivate } from './extension'; -const studioActivateMock = vi.fn(); -const studioDeactivateMock = vi.fn(); - -vi.mock('@podman-desktop/api', async () => { - return {}; -}); +const mocks = vi.hoisted(() => ({ + studioActivateMock: vi.fn(), + studioDeactivateMock: vi.fn(), + studioConstructor: vi.fn(), +})); -vi.mock('./studio', async () => { - return { - Studio: class { - public activate = studioActivateMock; - public deactivate = studioDeactivateMock; - }, - }; -}); +vi.mock('./studio', () => ({ + Studio: mocks.studioConstructor, +})); beforeEach(() => { vi.clearAllMocks(); + mocks.studioConstructor.mockReturnValue({ + activate: mocks.studioActivateMock, + deactivate: mocks.studioDeactivateMock, + }); }); -test('check we call activate method on studio ', async () => { - const fakeContext = {} as unknown as podmanDesktopApi.ExtensionContext; +test('check we call activate method on studio instance', async () => { + const fakeContext = {} as unknown as ExtensionContext; await activate(fakeContext); - // expect the activate method to be called on the bootc class - expect(studioActivateMock).toBeCalledTimes(1); + // expect the activate method to be called on the studio mock + expect(mocks.studioActivateMock).toBeCalledTimes(1); // no call on deactivate - expect(studioDeactivateMock).not.toBeCalled(); + expect(mocks.studioDeactivateMock).not.toBeCalled(); }); -test('check we call deactivate method on bootc ', async () => { +test('check we call deactivate method on studio instance ', async () => { await deactivate(); - // expect the activate method to be called on the bootc class - expect(studioDeactivateMock).toBeCalledTimes(1); + // expect the activate method to be called on the studio mock + expect(mocks.studioDeactivateMock).toBeCalledTimes(1); // no call on activate - expect(studioActivateMock).not.toBeCalled(); + expect(mocks.studioActivateMock).not.toBeCalled(); }); diff --git a/packages/backend/src/managers/SnippetManager.spec.ts b/packages/backend/src/managers/SnippetManager.spec.ts new file mode 100644 index 000000000..c10737954 --- /dev/null +++ b/packages/backend/src/managers/SnippetManager.spec.ts @@ -0,0 +1,116 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, expect, test, vi } from 'vitest'; +import { SnippetManager } from './SnippetManager'; +import type { Webview } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; + +const webviewMock = { + postMessage: vi.fn(), +} as unknown as Webview; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(webviewMock.postMessage).mockResolvedValue(undefined); +}); + +test('expect init to notify webview', () => { + const manager = new SnippetManager(webviewMock); + manager.init(); + + expect(webviewMock.postMessage).toHaveBeenCalledWith({ + id: Messages.MSG_SUPPORTED_LANGUAGES_UPDATE, + body: manager.getLanguageList(), + }); +}); + +test('expect postman-code-generators to have many languages available.', () => { + const manager = new SnippetManager(webviewMock); + manager.init(); + + expect(manager.getLanguageList().length).toBeGreaterThan(0); +}); + +test('expect postman-code-generators to have nodejs supported.', () => { + const manager = new SnippetManager(webviewMock); + manager.init(); + + const languages = manager.getLanguageList(); + const nodejs = languages.find(language => language.key === 'nodejs'); + expect(nodejs).toBeDefined(); + expect(nodejs.variants.length).toBeGreaterThan(0); + + const native = nodejs.variants.find(variant => variant.key === 'Request'); + expect(native).toBeDefined(); +}); + +test('expect postman-code-generators to generate proper nodejs native code', async () => { + const manager = new SnippetManager(webviewMock); + manager.init(); + + const snippet = await manager.generate( + { + url: 'http://localhost:8080', + }, + 'nodejs', + 'Request', + ); + expect(snippet).toBe(`var request = require('request'); +var options = { + 'method': 'GET', + 'url': 'http://localhost:8080', + 'headers': { + } +}; +request(options, function (error, response) { + if (error) throw new Error(error); + console.log(response.body); +}); +`); +}); + +test('expect snippet manager to have Quarkus Langchain4J supported.', () => { + const manager = new SnippetManager(webviewMock); + manager.init(); + + const languages = manager.getLanguageList(); + const java = languages.find(language => language.key === 'java'); + expect(java).toBeDefined(); + expect(java.variants.length).toBeGreaterThan(0); + + const quarkus_langchain4j = java.variants.find(variant => variant.key === 'Quarkus Langchain4J'); + expect(quarkus_langchain4j).toBeDefined(); +}); + +test('expect new variant to replace existing one if same name', () => { + const manager = new SnippetManager(webviewMock); + manager.init(); + + const languages = manager.getLanguageList(); + const java = languages.find(language => language.key === 'java'); + expect(java).toBeDefined(); + expect(java.variants.length).toBeGreaterThan(0); + + const oldVariantsNumber = java.variants.length; + manager.addVariant('java', java.variants[0].key, vi.fn()); + const languages_updated = manager.getLanguageList(); + const java_updated = languages_updated.find(language => language.key === 'java'); + expect(java_updated).toBeDefined(); + expect(java_updated.variants.length).equals(oldVariantsNumber); +}); diff --git a/packages/backend/src/managers/SnippetManager.ts b/packages/backend/src/managers/SnippetManager.ts new file mode 100644 index 000000000..71556cdea --- /dev/null +++ b/packages/backend/src/managers/SnippetManager.ts @@ -0,0 +1,80 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { Disposable, Webview } from '@podman-desktop/api'; +import { getLanguageList, convert, type Language } from 'postman-code-generators'; +import { Request } from 'postman-collection'; +import { Publisher } from '../utils/Publisher'; +import { Messages } from '@shared/Messages'; +import type { RequestOptions } from '@shared/src/models/RequestOptions'; +import { quarkusLangchain4Jgenerator } from './snippets/quarkus-snippet'; +import { javaOkHttpGenerator } from './snippets/java-okhttp-snippet'; + +type Generator = (requestOptions: RequestOptions) => Promise; + +export class SnippetManager extends Publisher implements Disposable { + #languages: Language[]; + #additionalGenerators: Map; + + constructor(webview: Webview) { + super(webview, Messages.MSG_SUPPORTED_LANGUAGES_UPDATE, () => this.getLanguageList()); + } + + addVariant(key: string, variant: string, generator: Generator): void { + const original = this.#languages; + const language = original.find((lang: Language) => lang.key === key); + if (language) { + if (!language.variants.find(v => v.key === variant)) { + language.variants.push({ key: variant }); + } + this.#additionalGenerators.set(`${key}/${variant}`, generator); + } + } + + getLanguageList(): Language[] { + return this.#languages; + } + + async generate(requestOptions: RequestOptions, language: string, variant: string): Promise { + const generator = this.#additionalGenerators.get(`${language}/${variant}`); + if (generator) { + return generator(requestOptions); + } + + return new Promise((resolve, reject) => { + const request = new Request(requestOptions); + convert(language, variant, request, {}, (error: unknown, snippet: string) => { + if (error) { + reject(error); + return; + } + resolve(snippet); + }); + }); + } + + init() { + this.#languages = getLanguageList(); + this.#additionalGenerators = new Map(); + this.addVariant('java', 'Quarkus Langchain4J', quarkusLangchain4Jgenerator); + this.addVariant('java', 'OkHttp', javaOkHttpGenerator); + // Notify the publisher + this.notify(); + } + + dispose(): void {} +} diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index db061192a..ff364c091 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -1,66 +1,165 @@ -import { type MockInstance, describe, expect, test, vi, beforeEach } from 'vitest'; -import type { ImageInfo, PodInfo } from './applicationManager'; -import { ApplicationManager } from './applicationManager'; -import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import type { ContainerAttachedInfo, ImageInfo, ApplicationPodInfo } from './applicationManager'; +import { LABEL_RECIPE_ID, ApplicationManager } from './applicationManager'; import type { GitManager } from './gitManager'; import os from 'os'; import fs from 'node:fs'; import type { Recipe } from '@shared/src/models/IRecipe'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; -import { RecipeStatusUtils } from '../utils/recipeStatusUtils'; -import type { ModelsManager } from './modelsManager'; +import { ModelsManager } from './modelsManager'; import path from 'node:path'; import type { AIConfig, ContainerConfig } from '../models/AIConfig'; import * as portsUtils from '../utils/ports'; +import { goarch } from '../utils/arch'; +import * as utils from '../utils/utils'; +import * as podman from '../utils/podman'; +import type { Webview, TelemetryLogger, PodInfo } from '@podman-desktop/api'; +import type { CatalogManager } from './catalogManager'; +import type { LocalRepositoryRegistry } from '../registries/LocalRepositoryRegistry'; +import type { + PodmanConnection, + machineStopHandle, + podRemoveHandle, + podStartHandle, + podStopHandle, + startupHandle, +} from './podmanConnection'; +import { TaskRegistry } from '../registries/TaskRegistry'; const mocks = vi.hoisted(() => { return { - parseYamlMock: vi.fn(), - builImageMock: vi.fn(), + parseYamlFileMock: vi.fn(), + buildImageMock: vi.fn(), listImagesMock: vi.fn(), getImageInspectMock: vi.fn(), createPodMock: vi.fn(), createContainerMock: vi.fn(), - replicatePodmanContainerMock: vi.fn(), + startContainerMock: vi.fn(), + startPod: vi.fn(), + inspectContainerMock: vi.fn(), + logUsageMock: vi.fn(), + logErrorMock: vi.fn(), + registerLocalRepositoryMock: vi.fn(), + postMessageMock: vi.fn(), + getContainerConnectionsMock: vi.fn(), + pullImageMock: vi.fn(), + stopContainerMock: vi.fn(), + getFreePortMock: vi.fn(), + containerRegistrySubscribeMock: vi.fn(), + onPodStartMock: vi.fn(), + onPodStopMock: vi.fn(), + onPodRemoveMock: vi.fn(), + startupSubscribeMock: vi.fn(), + onMachineStopMock: vi.fn(), + listContainersMock: vi.fn(), + listPodsMock: vi.fn(), + stopPodMock: vi.fn(), + removePodMock: vi.fn(), + performDownloadMock: vi.fn(), + getTargetMock: vi.fn(), + onEventDownloadMock: vi.fn(), + // TaskRegistry + getTaskMock: vi.fn(), + createTaskMock: vi.fn(), + updateTaskMock: vi.fn(), + deleteMock: vi.fn(), + deleteAllMock: vi.fn(), + getTasksMock: vi.fn(), + getTasksByLabelsMock: vi.fn(), + deleteByLabelsMock: vi.fn(), }; }); vi.mock('../models/AIConfig', () => ({ - parseYaml: mocks.parseYamlMock, + parseYamlFile: mocks.parseYamlFileMock, })); + +vi.mock('../utils/downloader', () => ({ + Downloader: class { + onEvent = mocks.onEventDownloadMock; + perform = mocks.performDownloadMock; + getTarget = mocks.getTargetMock; + }, +})); + vi.mock('@podman-desktop/api', () => ({ + provider: { + getContainerConnections: mocks.getContainerConnectionsMock, + }, containerEngine: { - buildImage: mocks.builImageMock, + buildImage: mocks.buildImageMock, listImages: mocks.listImagesMock, getImageInspect: mocks.getImageInspectMock, createPod: mocks.createPodMock, createContainer: mocks.createContainerMock, - replicatePodmanContainer: mocks.replicatePodmanContainerMock, + startContainer: mocks.startContainerMock, + startPod: mocks.startPod, + inspectContainer: mocks.inspectContainerMock, + pullImage: mocks.pullImageMock, + stopContainer: mocks.stopContainerMock, + listContainers: mocks.listContainersMock, + listPods: mocks.listPodsMock, + stopPod: mocks.stopPodMock, + removePod: mocks.removePodMock, }, })); -let setTaskMock: MockInstance; -let taskUtils: RecipeStatusUtils; -let setTaskStateMock: MockInstance; + +const telemetryLogger = { + logUsage: mocks.logUsageMock, + logError: mocks.logErrorMock, +} as unknown as TelemetryLogger; + +const taskRegistry = { + getTask: mocks.getTaskMock, + createTask: mocks.createTaskMock, + updateTask: mocks.updateTaskMock, + delete: mocks.deleteMock, + deleteAll: mocks.deleteAllMock, + getTasks: mocks.getTasksMock, + getTasksByLabels: mocks.getTasksByLabelsMock, + deleteByLabels: mocks.deleteByLabelsMock, +} as unknown as TaskRegistry; + +const localRepositoryRegistry = { + register: mocks.registerLocalRepositoryMock, +} as unknown as LocalRepositoryRegistry; + beforeEach(() => { vi.resetAllMocks(); - taskUtils = new RecipeStatusUtils('recipe', { - setStatus: vi.fn(), - } as unknown as RecipeStatusRegistry); - setTaskMock = vi.spyOn(taskUtils, 'setTask'); - setTaskStateMock = vi.spyOn(taskUtils, 'setTaskState'); + + mocks.createTaskMock.mockImplementation((name, state, labels) => ({ + id: 'random', + name: name, + state: state, + labels: labels ?? {}, + error: undefined, + })); }); + describe('pullApplication', () => { interface mockForPullApplicationOptions { recipeFolderExists: boolean; } - const setStatusMock = vi.fn(); - const cloneRepositoryMock = vi.fn(); - const isModelOnDiskMock = vi.fn(); - const getLocalModelPathMock = vi.fn(); + const processCheckoutMock = vi.fn(); let manager: ApplicationManager; - let doDownloadModelWrapperSpy: MockInstance< - [modelId: string, url: string, taskUtil: RecipeStatusUtils, destFileName?: string], - Promise - >; + let modelsManager: ModelsManager; + vi.spyOn(utils, 'timeout').mockResolvedValue(); function mockForPullApplication(options: mockForPullApplicationOptions) { vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined); @@ -88,32 +187,32 @@ describe('pullApplication', () => { vi.spyOn(fs, 'readFileSync').mockImplementation((_path: string) => { return ''; }); - mocks.parseYamlMock.mockReturnValue({ + mocks.parseYamlFileMock.mockReturnValue({ application: { containers: [ { name: 'container1', contextdir: 'contextdir1', containerfile: 'Containerfile', + arch: [goarch()], + gpu_env: [], }, ], }, }); - mocks.builImageMock.mockResolvedValue(undefined); + mocks.inspectContainerMock.mockResolvedValue({ + State: { + Running: true, + }, + }); + mocks.buildImageMock.mockResolvedValue(undefined); mocks.listImagesMock.mockResolvedValue([ { - RepoTags: ['container1:latest'], + RepoTags: ['recipe1-container1:latest'], engineId: 'engine', Id: 'id1', }, ]); - mocks.getImageInspectMock.mockResolvedValue({ - Config: { - ExposedPorts: { - '8080': '8080', - }, - }, - }); mocks.createPodMock.mockResolvedValue({ engineId: 'engine', Id: 'id', @@ -121,32 +220,47 @@ describe('pullApplication', () => { mocks.createContainerMock.mockResolvedValue({ id: 'id', }); + modelsManager = new ModelsManager( + 'appdir', + {} as Webview, + { + getModels(): ModelInfo[] { + return []; + }, + } as CatalogManager, + telemetryLogger, + new TaskRegistry({ postMessage: vi.fn().mockResolvedValue(undefined) } as unknown as Webview), + ); manager = new ApplicationManager( '/home/user/aistudio', { - cloneRepository: cloneRepositoryMock, + processCheckout: processCheckoutMock, + isGitInstalled: () => true, } as unknown as GitManager, - { - setStatus: setStatusMock, - } as unknown as RecipeStatusRegistry, - { - isModelOnDisk: isModelOnDiskMock, - getLocalModelPath: getLocalModelPathMock, - } as unknown as ModelsManager, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, + modelsManager, + telemetryLogger, + localRepositoryRegistry, ); - doDownloadModelWrapperSpy = vi.spyOn(manager, 'doDownloadModelWrapper'); - doDownloadModelWrapperSpy.mockResolvedValue('path'); } test('pullApplication should clone repository and call downloadModelMain and buildImage', async () => { mockForPullApplication({ recipeFolderExists: false, }); - isModelOnDiskMock.mockReturnValue(false); + mocks.listPodsMock.mockResolvedValue([]); + vi.spyOn(podman, 'isQEMUMachine').mockResolvedValue(false); + vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(false); + vi.spyOn(modelsManager, 'uploadModelToPodmanMachine').mockResolvedValue('path'); + mocks.performDownloadMock.mockResolvedValue('path'); const recipe: Recipe = { id: 'recipe1', name: 'Recipe 1', categories: [], description: '', + ref: '000000', readme: '', repository: 'repo', }; @@ -156,55 +270,59 @@ describe('pullApplication', () => { hw: '', license: '', name: 'Model 1', - popularity: 1, registry: '', url: '', }; + mocks.inspectContainerMock.mockResolvedValue({ + State: { + Running: true, + }, + }); + vi.spyOn(utils, 'getDurationSecondsSince').mockReturnValue(99); await manager.pullApplication(recipe, model); + const gitCloneOptions = { + repository: 'repo', + ref: '000000', + targetDirectory: '\\home\\user\\aistudio\\recipe1', + }; if (process.platform === 'win32') { - expect(cloneRepositoryMock).toHaveBeenNthCalledWith(1, 'repo', '\\home\\user\\aistudio\\recipe1'); + expect(processCheckoutMock).toHaveBeenNthCalledWith(1, gitCloneOptions); } else { - expect(cloneRepositoryMock).toHaveBeenNthCalledWith(1, 'repo', '/home/user/aistudio/recipe1'); + gitCloneOptions.targetDirectory = '/home/user/aistudio/recipe1'; + expect(processCheckoutMock).toHaveBeenNthCalledWith(1, gitCloneOptions); } - expect(doDownloadModelWrapperSpy).toHaveBeenCalledOnce(); - expect(mocks.builImageMock).toHaveBeenCalledOnce(); - }); - test('pullApplication should not clone repository if folder already exists locally', async () => { - mockForPullApplication({ - recipeFolderExists: true, + expect(mocks.performDownloadMock).toHaveBeenCalledOnce(); + expect(mocks.buildImageMock).toHaveBeenCalledOnce(); + expect(mocks.buildImageMock).toHaveBeenCalledWith( + `${gitCloneOptions.targetDirectory}${path.sep}contextdir1`, + expect.anything(), + { + containerFile: 'Containerfile', + tag: 'recipe1-container1:latest', + labels: { + [LABEL_RECIPE_ID]: 'recipe1', + }, + }, + ); + expect(mocks.logUsageMock).toHaveBeenNthCalledWith(1, 'recipe.pull', { + 'recipe.id': 'recipe1', + 'recipe.name': 'Recipe 1', + durationSeconds: 99, }); - isModelOnDiskMock.mockReturnValue(false); - const recipe: Recipe = { - id: 'recipe1', - name: 'Recipe 1', - categories: [], - description: '', - readme: '', - repository: 'repo', - }; - const model: ModelInfo = { - id: 'model1', - description: '', - hw: '', - license: '', - name: 'Model 1', - popularity: 1, - registry: '', - url: '', - }; - await manager.pullApplication(recipe, model); - expect(cloneRepositoryMock).not.toHaveBeenCalled(); }); test('pullApplication should not download model if already on disk', async () => { mockForPullApplication({ recipeFolderExists: true, }); - isModelOnDiskMock.mockReturnValue(true); - getLocalModelPathMock.mockReturnValue('path'); + mocks.listPodsMock.mockResolvedValue([]); + vi.spyOn(modelsManager, 'isModelOnDisk').mockReturnValue(true); + vi.spyOn(modelsManager, 'uploadModelToPodmanMachine').mockResolvedValue('path'); + vi.spyOn(modelsManager, 'getLocalModelPath').mockReturnValue('path'); const recipe: Recipe = { id: 'recipe1', name: 'Recipe 1', categories: [], + ref: '000000', description: '', readme: '', repository: 'repo', @@ -215,13 +333,11 @@ describe('pullApplication', () => { hw: '', license: '', name: 'Model 1', - popularity: 1, registry: '', url: '', }; await manager.pullApplication(recipe, model); - expect(cloneRepositoryMock).not.toHaveBeenCalled(); - expect(doDownloadModelWrapperSpy).not.toHaveBeenCalled(); + expect(mocks.performDownloadMock).not.toHaveBeenCalled(); }); test('pullApplication should mark the loading config as error if not container are found', async () => { @@ -234,6 +350,7 @@ describe('pullApplication', () => { name: 'Recipe 1', categories: [], description: '', + ref: '000000', readme: '', repository: 'repo', }; @@ -243,74 +360,18 @@ describe('pullApplication', () => { hw: '', license: '', name: 'Model 1', - popularity: 1, registry: '', url: '', }; - mocks.parseYamlMock.mockReturnValue({ + mocks.parseYamlFileMock.mockReturnValue({ application: { containers: [], }, }); await expect(manager.pullApplication(recipe, model)).rejects.toThrowError('No containers available.'); - - expect(cloneRepositoryMock).not.toHaveBeenCalled(); - expect(doDownloadModelWrapperSpy).not.toHaveBeenCalled(); - }); -}); -describe('doCheckout', () => { - test('clone repo if not present locally', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - vi.spyOn(fs, 'mkdirSync'); - const cloneRepositoryMock = vi.fn(); - const manager = new ApplicationManager( - '/home/user/aistudio', - { - cloneRepository: cloneRepositoryMock, - } as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, - {} as unknown as ModelsManager, - ); - await manager.doCheckout('repo', 'folder', taskUtils); - expect(cloneRepositoryMock).toBeCalledWith('repo', 'folder'); - expect(setTaskMock).toHaveBeenLastCalledWith({ - id: 'checkout', - name: 'Checkout repository', - state: 'success', - labels: { - git: 'checkout', - }, - }); - }); - test('do not clone repo if already present locally', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - const stats = { - isDirectory: vi.fn().mockReturnValue(true), - } as unknown as fs.Stats; - vi.spyOn(fs, 'statSync').mockReturnValue(stats); - const mkdirSyncMock = vi.spyOn(fs, 'mkdirSync'); - const cloneRepositoryMock = vi.fn(); - const manager = new ApplicationManager( - '/home/user/aistudio', - { - cloneRepository: cloneRepositoryMock, - } as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, - {} as unknown as ModelsManager, - ); - await manager.doCheckout('repo', 'folder', taskUtils); - expect(mkdirSyncMock).not.toHaveBeenCalled(); - expect(cloneRepositoryMock).not.toHaveBeenCalled(); - expect(setTaskMock).toHaveBeenLastCalledWith({ - id: 'checkout', - name: 'Checkout repository (cached).', - state: 'success', - labels: { - git: 'checkout', - }, - }); + expect(mocks.performDownloadMock).not.toHaveBeenCalled(); }); }); @@ -319,8 +380,13 @@ describe('getConfiguration', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); vi.spyOn(fs, 'existsSync').mockReturnValue(false); expect(() => manager.getConfiguration('config', 'local')).toThrowError( @@ -332,8 +398,13 @@ describe('getConfiguration', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); vi.spyOn(fs, 'existsSync').mockReturnValue(true); const stats = { @@ -352,7 +423,7 @@ describe('getConfiguration', () => { ], }, }; - mocks.parseYamlMock.mockReturnValue(aiConfig); + mocks.parseYamlFileMock.mockReturnValue(aiConfig); const result = manager.getConfiguration('config', 'local'); expect(result.path).toEqual(path.join('local', 'config')); @@ -360,70 +431,6 @@ describe('getConfiguration', () => { }); }); -describe('downloadModel', () => { - test('download model if not already on disk', async () => { - const isModelOnDiskMock = vi.fn().mockReturnValue(false); - const manager = new ApplicationManager( - '/home/user/aistudio', - {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, - { isModelOnDisk: isModelOnDiskMock } as unknown as ModelsManager, - ); - const doDownloadModelWrapperMock = vi - .spyOn(manager, 'doDownloadModelWrapper') - .mockImplementation((_modelId: string, _url: string, _taskUtil: RecipeStatusUtils, _destFileName?: string) => { - return Promise.resolve(''); - }); - await manager.downloadModel( - { - id: 'id', - url: 'url', - name: 'name', - } as ModelInfo, - taskUtils, - ); - expect(doDownloadModelWrapperMock).toBeCalledWith('id', 'url', taskUtils); - expect(setTaskMock).toHaveBeenLastCalledWith({ - id: 'id', - name: 'Downloading model name', - labels: { - 'model-pulling': 'id', - }, - state: 'loading', - }); - }); - test('retrieve model path if already on disk', async () => { - const isModelOnDiskMock = vi.fn().mockReturnValue(true); - const getLocalModelPathMock = vi.fn(); - const manager = new ApplicationManager( - '/home/user/aistudio', - {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, - { - isModelOnDisk: isModelOnDiskMock, - getLocalModelPath: getLocalModelPathMock, - } as unknown as ModelsManager, - ); - await manager.downloadModel( - { - id: 'id', - url: 'url', - name: 'name', - } as ModelInfo, - taskUtils, - ); - expect(getLocalModelPathMock).toBeCalledWith('id'); - expect(setTaskMock).toHaveBeenLastCalledWith({ - id: 'id', - name: 'Model name already present on disk', - labels: { - 'model-pulling': 'id', - }, - state: 'success', - }); - }); -}); - describe('filterContainers', () => { test('return empty array when no container fit the system', () => { const aiConfig: AIConfig = { @@ -433,8 +440,9 @@ describe('filterContainers', () => { name: 'container2', contextdir: 'contextdir2', containerfile: 'Containerfile', - arch: 'arm64', + arch: ['arm64'], modelService: false, + gpu_env: [], }, ], }, @@ -445,8 +453,13 @@ describe('filterContainers', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); const containers = manager.filterContainers(aiConfig); expect(containers.length).toBe(0); @@ -459,15 +472,17 @@ describe('filterContainers', () => { name: 'container1', contextdir: 'contextdir1', containerfile: 'Containerfile', - arch: 'amd64', + arch: ['amd64'], modelService: false, + gpu_env: [], }, { name: 'container2', contextdir: 'contextdir2', containerfile: 'Containerfile', - arch: 'arm64', + arch: ['arm64'], modelService: false, + gpu_env: [], }, ], }, @@ -478,8 +493,13 @@ describe('filterContainers', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); const containers = manager.filterContainers(aiConfig); expect(containers.length).toBe(1); @@ -491,22 +511,25 @@ describe('filterContainers', () => { name: 'container1', contextdir: 'contextdir1', containerfile: 'Containerfile', - arch: 'amd64', + arch: ['amd64'], modelService: false, + gpu_env: [], }, { name: 'container2', contextdir: 'contextdir2', containerfile: 'Containerfile', - arch: 'arm64', + arch: ['arm64'], modelService: false, + gpu_env: [], }, { name: 'container3', contextdir: 'contextdir3', containerfile: 'Containerfile', - arch: 'amd64', + arch: ['amd64'], modelService: false, + gpu_env: [], }, ]; const aiConfig: AIConfig = { @@ -520,8 +543,13 @@ describe('filterContainers', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); const containers = manager.filterContainers(aiConfig); expect(containers.length).toBe(2); @@ -535,8 +563,13 @@ describe('getRandomName', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); const randomName = manager.getRandomName('base'); expect(randomName).not.equal('base'); @@ -546,8 +579,13 @@ describe('getRandomName', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); const randomName = manager.getRandomName(''); expect(randomName.length).toBeGreaterThan(0); @@ -555,65 +593,85 @@ describe('getRandomName', () => { }); describe('buildImages', () => { + const recipe = { + id: 'recipe1', + } as Recipe; const containers: ContainerConfig[] = [ { name: 'container1', contextdir: 'contextdir1', containerfile: 'Containerfile', - arch: 'amd64', + arch: ['amd64'], modelService: false, + gpu_env: [], + ports: [8080], }, ]; const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); test('setTaskState should be called with error if context does not exist', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); mocks.listImagesMock.mockRejectedValue([]); - await expect(manager.buildImages(containers, 'config', taskUtils)).rejects.toThrow( + await expect(manager.buildImages(recipe, containers, 'config')).rejects.toThrow( 'Context configured does not exist.', ); }); test('setTaskState should be called with error if buildImage executon fails', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - mocks.builImageMock.mockRejectedValue('error'); + mocks.buildImageMock.mockRejectedValue('error'); mocks.listImagesMock.mockRejectedValue([]); - await expect(manager.buildImages(containers, 'config', taskUtils)).rejects.toThrow( + await expect(manager.buildImages(recipe, containers, 'config')).rejects.toThrow( 'Something went wrong while building the image: error', ); - expect(setTaskStateMock).toBeCalledWith('container1', 'error'); + expect(mocks.updateTaskMock).toBeCalledWith({ + error: 'Something went wrong while building the image: error', + name: 'Building container1', + id: expect.any(String), + state: expect.any(String), + labels: {}, + }); }); test('setTaskState should be called with error if unable to find the image after built', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - mocks.builImageMock.mockResolvedValue({}); + mocks.buildImageMock.mockResolvedValue({}); mocks.listImagesMock.mockResolvedValue([]); - await expect(manager.buildImages(containers, 'config', taskUtils)).rejects.toThrow( + await expect(manager.buildImages(recipe, containers, 'config')).rejects.toThrow( 'no image found for container1:latest', ); - expect(setTaskStateMock).toBeCalledWith('container1', 'error'); + expect(mocks.updateTaskMock).toBeCalledWith({ + error: 'no image found for container1:latest', + name: 'Building container1', + id: expect.any(String), + state: expect.any(String), + labels: {}, + }); }); test('succeed if building image do not fail', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); - mocks.builImageMock.mockResolvedValue({}); + mocks.buildImageMock.mockResolvedValue({}); mocks.listImagesMock.mockResolvedValue([ { - RepoTags: ['container1:latest'], + RepoTags: ['recipe1-container1:latest'], engineId: 'engine', Id: 'id1', }, ]); - mocks.getImageInspectMock.mockResolvedValue({ - Config: { - ExposedPorts: { - '8080': '8080', - }, - }, + const imageInfoList = await manager.buildImages(recipe, containers, 'config'); + expect(mocks.updateTaskMock).toBeCalledWith({ + name: 'Building container1', + id: expect.any(String), + state: 'success', + labels: {}, }); - const imageInfoList = await manager.buildImages(containers, 'config', taskUtils); - expect(setTaskStateMock).toBeCalledWith('container1', 'success'); expect(imageInfoList.length).toBe(1); expect(imageInfoList[0].ports.length).toBe(1); expect(imageInfoList[0].ports[0]).equals('8080'); @@ -625,7 +683,7 @@ describe('createPod', async () => { id: 'id', appName: 'appName', modelService: false, - ports: ['8080'], + ports: ['8080', '8081'], }; const imageInfo2: ImageInfo = { id: 'id2', @@ -636,29 +694,62 @@ describe('createPod', async () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); test('throw an error if there is no sample image', async () => { const images = [imageInfo2]; - await expect(manager.createPod(images)).rejects.toThrowError('no sample app found'); + await expect( + manager.createPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images), + ).rejects.toThrowError('no sample app found'); }); test('call createPod with sample app exposed port', async () => { const images = [imageInfo1, imageInfo2]; vi.spyOn(manager, 'getRandomName').mockReturnValue('name'); - vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValue('9000'); - await manager.createPod(images); + vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9000'); + vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9001'); + vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9002'); + mocks.createPodMock.mockResolvedValue({ + Id: 'podId', + engineId: 'engineId', + }); + await manager.createPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images); expect(mocks.createPodMock).toBeCalledWith({ name: 'name', portmappings: [ { container_port: 8080, + host_port: 9002, + host_ip: '', + protocol: '', + range: 1, + }, + { + container_port: 8081, + host_port: 9001, + host_ip: '', + protocol: '', + range: 1, + }, + { + container_port: 8082, host_port: 9000, host_ip: '', protocol: '', range: 1, }, ], + labels: { + 'ai-studio-recipe-id': 'recipe-id', + 'ai-studio-app-ports': '9002,9001', + 'ai-studio-model-id': 'model-id', + 'ai-studio-model-ports': '9000', + }, }); }); }); @@ -668,7 +759,7 @@ describe('createApplicationPod', () => { id: 'id', appName: 'appName', modelService: false, - ports: ['8080'], + ports: ['8080', '8081'], }; const imageInfo2: ImageInfo = { id: 'id2', @@ -679,34 +770,499 @@ describe('createApplicationPod', () => { const manager = new ApplicationManager( '/home/user/aistudio', {} as unknown as GitManager, - {} as unknown as RecipeStatusRegistry, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, ); const images = [imageInfo1, imageInfo2]; test('throw if createPod fails', async () => { vi.spyOn(manager, 'createPod').mockRejectedValue('error createPod'); - await expect(manager.createApplicationPod(images, 'path', taskUtils)).rejects.toThrowError('error createPod'); - expect(setTaskMock).toBeCalledWith({ - id: 'fake-pod-id', + await expect( + manager.createApplicationPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images, 'path'), + ).rejects.toThrowError('error createPod'); + expect(mocks.updateTaskMock).toBeCalledWith({ + error: 'Something went wrong while creating pod: error createPod', + id: expect.any(String), state: 'error', - name: 'Creating application', + name: 'Creating AI App', + labels: {}, }); }); test('call createAndAddContainersToPod after pod is created', async () => { - const pod: PodInfo = { + const pod: ApplicationPodInfo = { engineId: 'engine', Id: 'id', + portmappings: [], }; vi.spyOn(manager, 'createPod').mockResolvedValue(pod); const createAndAddContainersToPodMock = vi .spyOn(manager, 'createAndAddContainersToPod') - .mockImplementation((_pod: PodInfo, _images: ImageInfo[], _modelPath: string) => Promise.resolve()); - await manager.createApplicationPod(images, 'path', taskUtils); + .mockImplementation((_pod: ApplicationPodInfo, _images: ImageInfo[], _modelPath: string) => Promise.resolve([])); + await manager.createApplicationPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images, 'path'); expect(createAndAddContainersToPodMock).toBeCalledWith(pod, images, 'path'); - expect(setTaskMock).toBeCalledWith({ - id: 'id', + expect(mocks.updateTaskMock).toBeCalledWith({ + id: expect.any(String), state: 'success', - name: 'Creating application', + name: 'Creating AI App', + labels: { + 'pod-id': pod.Id, + }, }); }); + test('throw if createAndAddContainersToPod fails', async () => { + const pod: ApplicationPodInfo = { + engineId: 'engine', + Id: 'id', + portmappings: [], + }; + vi.spyOn(manager, 'createPod').mockResolvedValue(pod); + vi.spyOn(manager, 'createAndAddContainersToPod').mockRejectedValue('error'); + await expect(() => + manager.createApplicationPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images, 'path'), + ).rejects.toThrowError('error'); + expect(mocks.updateTaskMock).toHaveBeenLastCalledWith({ + id: expect.any(String), + state: 'error', + error: 'Something went wrong while creating pod: error', + name: 'Creating AI App', + labels: { + 'pod-id': pod.Id, + }, + }); + }); +}); + +describe('runApplication', () => { + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, + {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, + ); + const pod: ApplicationPodInfo = { + engineId: 'engine', + Id: 'id', + containers: [ + { + name: 'first', + modelService: false, + ports: ['8080', '8081'], + }, + { + name: 'second', + modelService: true, + ports: ['9000'], + }, + ], + portmappings: [ + { + container_port: 9000, + host_port: 9001, + host_ip: '', + protocol: '', + range: -1, + }, + ], + }; + test('check startPod is called and also waitContainerIsRunning for sample app', async () => { + const waitContainerIsRunningMock = vi + .spyOn(manager, 'waitContainerIsRunning') + .mockImplementation((_engineId: string, _container: ContainerAttachedInfo) => Promise.resolve()); + vi.spyOn(utils, 'timeout').mockResolvedValue(); + await manager.runApplication(pod); + expect(mocks.startPod).toBeCalledWith(pod.engineId, pod.Id); + expect(waitContainerIsRunningMock).toBeCalledWith(pod.engineId, { + name: 'first', + modelService: false, + ports: ['8080', '8081'], + }); + }); +}); + +describe('createAndAddContainersToPod', () => { + const manager = new ApplicationManager( + '/home/user/aistudio', + {} as unknown as GitManager, + taskRegistry, + {} as Webview, + {} as PodmanConnection, + {} as CatalogManager, + {} as unknown as ModelsManager, + telemetryLogger, + localRepositoryRegistry, + ); + const pod: ApplicationPodInfo = { + engineId: 'engine', + Id: 'id', + portmappings: [], + }; + const imageInfo1: ImageInfo = { + id: 'id', + appName: 'appName', + modelService: false, + ports: ['8080', '8081'], + }; + const imageInfo2: ImageInfo = { + id: 'id2', + appName: 'appName2', + modelService: true, + ports: ['8085'], + }; + test('check that containers are correctly created', async () => { + mocks.createContainerMock.mockResolvedValue({ + id: 'container-1', + }); + vi.spyOn(podman, 'isQEMUMachine').mockResolvedValue(false); + vi.spyOn(manager, 'getRandomName').mockReturnValue('name'); + await manager.createAndAddContainersToPod(pod, [imageInfo1, imageInfo2], 'path'); + expect(mocks.createContainerMock).toHaveBeenNthCalledWith(1, 'engine', { + Image: 'id', + Detach: true, + Env: ['MODEL_ENDPOINT=http://localhost:8085'], + start: false, + name: 'name', + pod: 'id', + }); + expect(mocks.createContainerMock).toHaveBeenNthCalledWith(2, 'engine', { + Image: 'id2', + Detach: true, + Env: ['MODEL_PATH=/path'], + start: false, + name: 'name', + pod: 'id', + HostConfig: { + Mounts: [ + { + Mode: 'Z', + Source: 'path', + Target: '/path', + Type: 'bind', + }, + ], + }, + }); + }); +}); + +describe('pod detection', async () => { + let manager: ApplicationManager; + + beforeEach(() => { + vi.resetAllMocks(); + + mocks.createTaskMock.mockImplementation((name, state, labels) => ({ + id: 'random', + name: name, + state: state, + labels: labels ?? {}, + error: undefined, + })); + + manager = new ApplicationManager( + '/path/to/user/dir', + {} as GitManager, + taskRegistry, + { + postMessage: mocks.postMessageMock, + } as unknown as Webview, + { + onPodStart: mocks.onPodStartMock, + onPodStop: mocks.onPodStopMock, + onPodRemove: mocks.onPodRemoveMock, + startupSubscribe: mocks.startupSubscribeMock, + onMachineStop: mocks.onMachineStopMock, + } as unknown as PodmanConnection, + { + getRecipeById: vi.fn().mockReturnValue({ name: 'MyRecipe' } as Recipe), + } as unknown as CatalogManager, + {} as ModelsManager, + {} as TelemetryLogger, + localRepositoryRegistry, + ); + }); + + test('adoptRunningApplications updates the app state with the found pod', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + 'ai-studio-app-ports': '5000,5001', + 'ai-studio-model-ports': '8000,8001', + }, + }, + ]); + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + const updateApplicationStateSpy = vi.spyOn(manager, 'updateApplicationState'); + manager.adoptRunningApplications(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(updateApplicationStateSpy).toHaveBeenNthCalledWith(1, 'recipe-id-1', 'model-id-1', { + pod: { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + 'ai-studio-app-ports': '5000,5001', + 'ai-studio-model-ports': '8000,8001', + }, + }, + recipeId: 'recipe-id-1', + modelId: 'model-id-1', + appPorts: [5000, 5001], + modelPorts: [8000, 8001], + }); + const ports = await manager.getApplicationPorts('recipe-id-1', 'model-id-1'); + expect(ports).toStrictEqual([5000, 5001]); + }); + + test('adoptRunningApplications does not update the application state with the found pod without label', async () => { + mocks.listPodsMock.mockResolvedValue([{}]); + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + const updateApplicationStateSpy = vi.spyOn(manager, 'updateApplicationState'); + manager.adoptRunningApplications(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(updateApplicationStateSpy).not.toHaveBeenCalled(); + }); + + test('onMachineStop updates the applications state with no application running', async () => { + mocks.listPodsMock.mockResolvedValue([]); + mocks.onMachineStopMock.mockImplementation((f: machineStopHandle) => { + f(); + }); + const sendApplicationStateSpy = vi.spyOn(manager, 'notify').mockResolvedValue(); + manager.adoptRunningApplications(); + expect(sendApplicationStateSpy).toHaveBeenCalledOnce(); + }); + + test('onPodStart updates the applications state with the started pod', async () => { + mocks.listPodsMock.mockResolvedValue([]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodStartMock.mockImplementation((f: podStartHandle) => { + f({ + engineId: 'engine-1', + engineName: 'Engine 1', + kind: 'podman', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + } as unknown as PodInfo); + }); + const sendApplicationStateSpy = vi.spyOn(manager, 'notify').mockResolvedValue(); + manager.adoptRunningApplications(); + expect(sendApplicationStateSpy).toHaveBeenCalledOnce(); + }); + + test('onPodStart does no update the applications state with the started pod without labels', async () => { + mocks.listPodsMock.mockResolvedValue([]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodStartMock.mockImplementation((f: podStartHandle) => { + f({ + engineId: 'engine-1', + engineName: 'Engine 1', + kind: 'podman', + } as unknown as PodInfo); + }); + const sendApplicationStateSpy = vi.spyOn(manager, 'notify').mockResolvedValue(); + manager.adoptRunningApplications(); + expect(sendApplicationStateSpy).not.toHaveBeenCalledOnce(); + }); + + test('onPodStop updates the applications state by removing the stopped pod', async () => { + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + mocks.listPodsMock.mockResolvedValue([ + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + }, + ]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodStopMock.mockImplementation((f: podStopHandle) => { + setTimeout(() => { + f({ + engineId: 'engine-1', + engineName: 'Engine 1', + kind: 'podman', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + } as unknown as PodInfo); + }, 1); + }); + const sendApplicationStateSpy = vi.spyOn(manager, 'notify').mockResolvedValue(); + manager.adoptRunningApplications(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(sendApplicationStateSpy).toHaveBeenCalledTimes(2); + }); + + test('onPodRemove updates the applications state by removing the removed pod', async () => { + mocks.startupSubscribeMock.mockImplementation((f: startupHandle) => { + f(); + }); + mocks.listPodsMock.mockResolvedValue([ + { + Id: 'pod-id-1', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + }, + ]); + mocks.onMachineStopMock.mockImplementation((_f: machineStopHandle) => {}); + mocks.onPodRemoveMock.mockImplementation((f: podRemoveHandle) => { + setTimeout(() => { + f('pod-id-1'); + }, 1); + }); + const sendApplicationStateSpy = vi.spyOn(manager, 'notify').mockResolvedValue(); + manager.adoptRunningApplications(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(sendApplicationStateSpy).toHaveBeenCalledTimes(2); + }); + + test('getApplicationPod', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + }, + { + Labels: { + 'ai-studio-recipe-id': 'recipe-id-2', + 'ai-studio-model-id': 'model-id-2', + }, + }, + ]); + const result = await manager.getApplicationPod('recipe-id-1', 'model-id-1'); + expect(result).toEqual({ + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + }); + }); + + test('deleteApplication calls stopPod and removePod', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + engineId: 'engine-1', + Id: 'pod-1', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + }, + { + engineId: 'engine-2', + Id: 'pod-2', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-2', + 'ai-studio-model-id': 'model-id-2', + }, + }, + ]); + await manager.deleteApplication('recipe-id-1', 'model-id-1'); + expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + }); + + test('deleteApplication calls stopPod and removePod even if stopPod fails because pod already stopped', async () => { + mocks.listPodsMock.mockResolvedValue([ + { + engineId: 'engine-1', + Id: 'pod-1', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-1', + 'ai-studio-model-id': 'model-id-1', + }, + }, + { + engineId: 'engine-2', + Id: 'pod-2', + Labels: { + 'ai-studio-recipe-id': 'recipe-id-2', + 'ai-studio-model-id': 'model-id-2', + }, + }, + ]); + mocks.stopPodMock.mockRejectedValue('something went wrong, pod already stopped...'); + await manager.deleteApplication('recipe-id-1', 'model-id-1'); + expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + }); +}); + +describe('getImageTag', () => { + const manager = new ApplicationManager( + '/path/to/user/dir', + {} as GitManager, + taskRegistry, + { + postMessage: mocks.postMessageMock, + } as unknown as Webview, + { + onPodStart: mocks.onPodStartMock, + onPodStop: mocks.onPodStopMock, + onPodRemove: mocks.onPodRemoveMock, + startupSubscribe: mocks.startupSubscribeMock, + onMachineStop: mocks.onMachineStopMock, + } as unknown as PodmanConnection, + { + getRecipeById: vi.fn().mockReturnValue({ name: 'MyRecipe' } as Recipe), + } as unknown as CatalogManager, + {} as ModelsManager, + {} as TelemetryLogger, + localRepositoryRegistry, + ); + test('return recipe-container tag if container image prop is not defined', () => { + const recipe = { + id: 'recipe1', + } as Recipe; + const container = { + name: 'name', + } as ContainerConfig; + const imageTag = manager.getImageTag(recipe, container); + expect(imageTag).equals('recipe1-name:latest'); + }); + test('return container image prop is defined', () => { + const recipe = { + id: 'recipe1', + } as Recipe; + const container = { + name: 'name', + image: 'quay.io/repo/image:v1', + } as ContainerConfig; + const imageTag = manager.getImageTag(recipe, container); + expect(imageTag).equals('quay.io/repo/image:v1'); + }); + test('append latest tag to container image prop if it has no tag', () => { + const recipe = { + id: 'recipe1', + } as Recipe; + const container = { + name: 'name', + image: 'quay.io/repo/image', + } as ContainerConfig; + const imageTag = manager.getImageTag(recipe, container); + expect(imageTag).equals('quay.io/repo/image:latest'); + }); }); diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index c72a12dbe..c8ef93b0c 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -17,37 +17,60 @@ ***********************************************************************/ import type { Recipe } from '@shared/src/models/IRecipe'; -import { arch } from 'node:os'; -import type { GitManager } from './gitManager'; +import type { GitCloneInfo, GitManager } from './gitManager'; import fs from 'fs'; -import * as https from 'node:https'; import * as path from 'node:path'; -import { type PodCreatePortOptions, containerEngine } from '@podman-desktop/api'; -import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; +import { + type PodCreatePortOptions, + containerEngine, + type TelemetryLogger, + type PodInfo, + type Webview, + type HostConfig, +} from '@podman-desktop/api'; import type { AIConfig, AIConfigFile, ContainerConfig } from '../models/AIConfig'; -import { parseYaml } from '../models/AIConfig'; +import { parseYamlFile } from '../models/AIConfig'; import type { Task } from '@shared/src/models/ITask'; -import { RecipeStatusUtils } from '../utils/recipeStatusUtils'; import { getParentDirectory } from '../utils/pathUtils'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; import type { ModelsManager } from './modelsManager'; import { getPortsInfo } from '../utils/ports'; +import { goarch } from '../utils/arch'; +import { getDurationSecondsSince, timeout } from '../utils/utils'; +import type { LocalRepositoryRegistry } from '../registries/LocalRepositoryRegistry'; +import type { ApplicationState } from '@shared/src/models/IApplicationState'; +import type { PodmanConnection } from './podmanConnection'; +import { Messages } from '@shared/Messages'; +import type { CatalogManager } from './catalogManager'; +import { ApplicationRegistry } from '../registries/ApplicationRegistry'; +import type { TaskRegistry } from '../registries/TaskRegistry'; +import { Publisher } from '../utils/Publisher'; +import { isQEMUMachine } from '../utils/podman'; + +export const LABEL_MODEL_ID = 'ai-studio-model-id'; +export const LABEL_MODEL_PORTS = 'ai-studio-model-ports'; + +export const LABEL_RECIPE_ID = 'ai-studio-recipe-id'; +export const LABEL_APP_PORTS = 'ai-studio-app-ports'; export const CONFIG_FILENAME = 'ai-studio.yaml'; -interface DownloadModelResult { - result: 'ok' | 'failed'; - error?: string; -} - interface AIContainers { aiConfigFile: AIConfigFile; containers: ContainerConfig[]; } -export interface PodInfo { +export interface ContainerAttachedInfo { + name: string; + modelService: boolean; + ports: string[]; +} + +export interface ApplicationPodInfo { engineId: string; Id: string; + containers?: ContainerAttachedInfo[]; + portmappings: PodCreatePortOptions[]; } export interface ImageInfo { @@ -57,155 +80,294 @@ export interface ImageInfo { appName: string; } -export class ApplicationManager { +export class ApplicationManager extends Publisher { + #applications: ApplicationRegistry; + protectTasks: Set = new Set(); + constructor( private appUserDirectory: string, private git: GitManager, - private recipeStatusRegistry: RecipeStatusRegistry, + private taskRegistry: TaskRegistry, + webview: Webview, + private podmanConnection: PodmanConnection, + private catalogManager: CatalogManager, private modelsManager: ModelsManager, - ) {} + private telemetry: TelemetryLogger, + private localRepositories: LocalRepositoryRegistry, + ) { + super(webview, Messages.MSG_APPLICATIONS_STATE_UPDATE, () => this.getApplicationsState()); + this.#applications = new ApplicationRegistry(); + } async pullApplication(recipe: Recipe, model: ModelInfo) { - // Create a TaskUtils object to help us - const taskUtil = new RecipeStatusUtils(recipe.id, this.recipeStatusRegistry); + // clear any existing status / tasks related to the pair recipeId-modelId. + this.taskRegistry.deleteByLabels({ + 'recipe-id': recipe.id, + 'model-id': model.id, + }); + return this.startApplication(recipe, model); + } - const localFolder = path.join(this.appUserDirectory, recipe.id); + async startApplication(recipe: Recipe, model: ModelInfo) { + // const recipeStatus = this.recipeStatusRegistry. + const startTime = performance.now(); + try { + const localFolder = path.join(this.appUserDirectory, recipe.id); - // clone the recipe repository on the local folder - await this.doCheckout(recipe.repository, localFolder, taskUtil); + // clone the recipe repository on the local folder + const gitCloneInfo: GitCloneInfo = { + repository: recipe.repository, + ref: recipe.ref, + targetDirectory: localFolder, + }; + await this.doCheckout(gitCloneInfo, { + 'recipe-id': recipe.id, + 'model-id': model.id, + }); - // load and parse the recipe configuration file and filter containers based on architecture, gpu accelerator - // and backend (that define which model supports) - const configAndFilteredContainers = this.getConfigAndFilterContainers(recipe.config, localFolder, taskUtil); + this.localRepositories.register({ + path: gitCloneInfo.targetDirectory, + labels: { + 'recipe-id': recipe.id, + }, + }); - // get model by downloading it or retrieving locally - const modelPath = await this.downloadModel(model, taskUtil); + // load and parse the recipe configuration file and filter containers based on architecture, gpu accelerator + // and backend (that define which model supports) + const configAndFilteredContainers = this.getConfigAndFilterContainers(recipe.config, localFolder); - // build all images, one per container (for a basic sample we should have 2 containers = sample app + model service) - const images = await this.buildImages( - configAndFilteredContainers.containers, - configAndFilteredContainers.aiConfigFile.path, - taskUtil, - ); + // get model by downloading it or retrieving locally + let modelPath = await this.modelsManager.requestDownloadModel(model, { + 'recipe-id': recipe.id, + 'model-id': model.id, + }); - // create a pod containing all the containers to run the application - await this.createApplicationPod(images, modelPath, taskUtil); + // upload model to podman machine if user system is supported + modelPath = await this.modelsManager.uploadModelToPodmanMachine(model, { + 'recipe-id': recipe.id, + 'model-id': model.id, + }); + + // build all images, one per container (for a basic sample we should have 2 containers = sample app + model service) + const images = await this.buildImages( + recipe, + configAndFilteredContainers.containers, + configAndFilteredContainers.aiConfigFile.path, + { + 'recipe-id': recipe.id, + 'model-id': model.id, + }, + ); + + // first delete any existing pod with matching labels + if (await this.hasApplicationPod(recipe.id, model.id)) { + await this.deleteApplication(recipe.id, model.id); + } + + // create a pod containing all the containers to run the application + const podInfo = await this.createApplicationPod(recipe, model, images, modelPath, { + 'recipe-id': recipe.id, + 'model-id': model.id, + }); + + await this.runApplication(podInfo, { + 'recipe-id': recipe.id, + 'model-id': model.id, + }); + const durationSeconds = getDurationSecondsSince(startTime); + this.telemetry.logUsage('recipe.pull', { 'recipe.id': recipe.id, 'recipe.name': recipe.name, durationSeconds }); + } catch (err: unknown) { + const durationSeconds = getDurationSecondsSince(startTime); + this.telemetry.logError('recipe.pull', { + 'recipe.id': recipe.id, + 'recipe.name': recipe.name, + durationSeconds, + message: 'error pulling application', + error: err, + }); + throw err; + } + } + + async runApplication(podInfo: ApplicationPodInfo, labels?: { [key: string]: string }) { + const task = this.taskRegistry.createTask('Starting AI App', 'loading', labels); + + // it starts the pod + await containerEngine.startPod(podInfo.engineId, podInfo.Id); + + // check if all containers have started successfully + for (const container of podInfo.containers ?? []) { + await this.waitContainerIsRunning(podInfo.engineId, container); + } + + // Update task registry + this.taskRegistry.updateTask({ + ...task, + state: 'success', + name: 'AI App is running', + }); + } + + async waitContainerIsRunning(engineId: string, container: ContainerAttachedInfo): Promise { + const TIME_FRAME_MS = 5000; + const MAX_ATTEMPTS = 60 * (60000 / TIME_FRAME_MS); // try for 1 hour + for (let i = 0; i < MAX_ATTEMPTS; i++) { + const sampleAppContainerInspectInfo = await containerEngine.inspectContainer(engineId, container.name); + if (sampleAppContainerInspectInfo.State.Running) { + return; + } + await timeout(TIME_FRAME_MS); + } + throw new Error(`Container ${container.name} not started in time`); } - async createApplicationPod(images: ImageInfo[], modelPath: string, taskUtil: RecipeStatusUtils) { + async createApplicationPod( + recipe: Recipe, + model: ModelInfo, + images: ImageInfo[], + modelPath: string, + labels?: { [key: string]: string }, + ): Promise { + const task = this.taskRegistry.createTask('Creating AI App', 'loading', labels); + // create empty pod - let pod: PodInfo; + let podInfo: ApplicationPodInfo; try { - pod = await this.createPod(images); + podInfo = await this.createPod(recipe, model, images); + task.labels['pod-id'] = podInfo.Id; } catch (e) { - console.error('error when creating pod'); - taskUtil.setTask({ - id: 'fake-pod-id', - state: 'error', - name: 'Creating application', - }); + console.error('error when creating pod', e); + task.state = 'error'; + task.error = `Something went wrong while creating pod: ${String(e)}`; throw e; + } finally { + this.taskRegistry.updateTask(task); } - taskUtil.setTask({ - id: pod.Id, - state: 'loading', - name: `Creating application`, - }); - - await this.createAndAddContainersToPod(pod, images, modelPath); + let attachedContainers: ContainerAttachedInfo[]; + try { + attachedContainers = await this.createAndAddContainersToPod(podInfo, images, modelPath); + task.state = 'success'; + } catch (e) { + console.error(`error when creating pod ${podInfo.Id}`, e); + task.state = 'error'; + task.error = `Something went wrong while creating pod: ${String(e)}`; + throw e; + } finally { + this.taskRegistry.updateTask(task); + } - taskUtil.setTask({ - id: pod.Id, - state: 'success', - name: `Creating application`, - }); + podInfo.containers = attachedContainers; + return podInfo; } - async createAndAddContainersToPod(pod: PodInfo, images: ImageInfo[], modelPath: string) { + async createAndAddContainersToPod( + podInfo: ApplicationPodInfo, + images: ImageInfo[], + modelPath: string, + ): Promise { + const containers: ContainerAttachedInfo[] = []; + // temporary check to set Z flag or not - to be removed when switching to podman 5 + const isQEMUVM = await isQEMUMachine(); await Promise.all( images.map(async image => { - let hostConfig: unknown; + let hostConfig: HostConfig; let envs: string[] = []; // if it's a model service we mount the model as a volume if (image.modelService) { const modelName = path.basename(modelPath); hostConfig = { - AutoRemove: true, Mounts: [ { Target: `/${modelName}`, Source: modelPath, Type: 'bind', + Mode: isQEMUVM ? undefined : 'Z', }, ], }; envs = [`MODEL_PATH=/${modelName}`]; } else { - hostConfig = { - AutoRemove: true, - }; // TODO: remove static port const modelService = images.find(image => image.modelService); if (modelService && modelService.ports.length > 0) { - envs = [`MODEL_ENDPOINT=http://localhost:${modelService.ports[0]}`]; - } - } - const createdContainer = await containerEngine - .createContainer(pod.engineId, { - Image: image.id, - Detach: true, - HostConfig: hostConfig, - Env: envs, - start: false, - }) - .catch((e: unknown) => console.error(e)); - - // now, for each container, put it in the pod - if (createdContainer) { - try { - await containerEngine.replicatePodmanContainer( - { - id: createdContainer.id, - engineId: pod.engineId, - }, - { engineId: pod.engineId }, - { pod: pod.Id, name: this.getRandomName(`${image.appName}-podified`) }, - ); - } catch (error) { - console.error(error); + const endPoint = `http://localhost:${modelService.ports[0]}`; + envs = [`MODEL_ENDPOINT=${endPoint}`]; } } + + const podifiedName = this.getRandomName(`${image.appName}-podified`); + await containerEngine.createContainer(podInfo.engineId, { + Image: image.id, + name: podifiedName, + Detach: true, + HostConfig: hostConfig, + Env: envs, + start: false, + pod: podInfo.Id, + }); + containers.push({ + name: podifiedName, + modelService: image.modelService, + ports: image.ports, + }); }), ); + return containers; } - async createPod(images: ImageInfo[]): Promise { + async createPod(recipe: Recipe, model: ModelInfo, images: ImageInfo[]): Promise { // find the exposed port of the sample app so we can open its ports on the new pod const sampleAppImageInfo = images.find(image => !image.modelService); if (!sampleAppImageInfo) { - console.error('no image found'); + console.error('no sample app image found'); throw new Error('no sample app found'); } const portmappings: PodCreatePortOptions[] = []; - // N.B: it may not work with ranges - for (const exposed of sampleAppImageInfo.ports) { - const localPorts = await getPortsInfo(exposed); - portmappings.push({ - container_port: parseInt(exposed), - host_port: parseInt(localPorts), - host_ip: '', - protocol: '', - range: 1, - }); + // we expose all ports so we can check the model service if it is actually running + for (const image of images) { + for (const exposed of image.ports) { + const localPorts = await getPortsInfo(exposed); + portmappings.push({ + container_port: parseInt(exposed), + host_port: parseInt(localPorts), + host_ip: '', + protocol: '', + range: 1, + }); + } } // create new pod - return await containerEngine.createPod({ + const labels = { + [LABEL_RECIPE_ID]: recipe.id, + [LABEL_MODEL_ID]: model.id, + }; + const modelPorts = images + .filter(img => img.modelService) + .flatMap(img => img.ports) + .map(port => portmappings.find(pm => `${pm.container_port}` === port)?.host_port); + if (modelPorts.length) { + labels[LABEL_MODEL_PORTS] = modelPorts.join(','); + } + const appPorts = images + .filter(img => !img.modelService) + .flatMap(img => img.ports) + .map(port => portmappings.find(pm => `${pm.container_port}` === port)?.host_port); + if (appPorts.length) { + labels[LABEL_APP_PORTS] = appPorts.join(','); + } + const pod = await containerEngine.createPod({ name: this.getRandomName(`pod-${sampleAppImageInfo.appName}`), portmappings: portmappings, + labels, }); + return { + Id: pod.Id, + engineId: pod.engineId, + portmappings: portmappings, + }; } getRandomName(base: string): string { @@ -213,37 +375,43 @@ export class ApplicationManager { } async buildImages( + recipe: Recipe, containers: ContainerConfig[], configPath: string, - taskUtil: RecipeStatusUtils, + labels?: { [key: string]: string }, ): Promise { - containers.forEach(container => { - taskUtil.setTask({ - id: container.name, - state: 'loading', - name: `Building ${container.name}`, - }); - }); + const containerTasks: { [key: string]: Task } = Object.fromEntries( + containers.map(container => [ + container.name, + this.taskRegistry.createTask(`Building ${container.name}`, 'loading', labels), + ]), + ); const imageInfoList: ImageInfo[] = []; // Promise all the build images await Promise.all( containers.map(container => { + const task = containerTasks[container.name]; + // We use the parent directory of our configFile as the rootdir, then we append the contextDir provided const context = path.join(getParentDirectory(configPath), container.contextdir); console.log(`Application Manager using context ${context} for container ${container.name}`); // Ensure the context provided exist otherwise throw an Error if (!fs.existsSync(context)) { - console.error('The context provided does not exist.'); - taskUtil.setTaskState(container.name, 'error'); + task.error = 'The context provided does not exist.'; + this.taskRegistry.updateTask(task); throw new Error('Context configured does not exist.'); } + const imageTag = this.getImageTag(recipe, container); const buildOptions = { containerFile: container.containerfile, - tag: `${container.name}:latest`, + tag: imageTag, + labels: { + [LABEL_RECIPE_ID]: labels !== undefined && 'recipe-id' in labels ? labels['recipe-id'] : undefined, + }, }; return containerEngine @@ -253,14 +421,15 @@ export class ApplicationManager { // todo: do something with the event if (event === 'error' || (event === 'finish' && data !== '')) { console.error('Something went wrong while building the image: ', data); - taskUtil.setTaskState(container.name, 'error'); + task.error = `Something went wrong while building the image: ${data}`; + this.taskRegistry.updateTask(task); } }, buildOptions, ) .catch((err: unknown) => { - console.error('Something went wrong while building the image: ', err); - taskUtil.setTaskState(container.name, 'error'); + task.error = `Something went wrong while building the image: ${String(err)}`; + this.taskRegistry.updateTask(task); throw new Error(`Something went wrong while building the image: ${String(err)}`); }); }), @@ -270,54 +439,57 @@ export class ApplicationManager { const images = await containerEngine.listImages(); await Promise.all( containers.map(async container => { + const task = containerTasks[container.name]; + const imageTag = this.getImageTag(recipe, container); + const image = images.find(im => { - return im.RepoTags?.some(tag => tag.endsWith(`${container.name}:latest`)); + return im.RepoTags?.some(tag => tag.endsWith(imageTag)); }); if (!image) { - console.error('no image found'); - taskUtil.setTaskState(container.name, 'error'); + task.error = `no image found for ${container.name}:latest`; + this.taskRegistry.updateTask(task); throw new Error(`no image found for ${container.name}:latest`); } - const imageInspectInfo = await containerEngine.getImageInspect(image.engineId, image.Id); - const exposedPorts = Array.from(Object.keys(imageInspectInfo?.Config?.ExposedPorts || {})).map(port => { - if (port.endsWith('/tcp') || port.endsWith('/udp')) { - return port.substring(0, port.length - 4); - } - return port; - }); - imageInfoList.push({ id: image.Id, modelService: container.modelService, - ports: exposedPorts, + ports: container.ports?.map(p => `${p}`) ?? [], appName: container.name, }); - taskUtil.setTaskState(container.name, 'success'); + task.state = 'success'; + this.taskRegistry.updateTask(task); }), ); return imageInfoList; } - getConfigAndFilterContainers(recipeConfig: string, localFolder: string, taskUtil: RecipeStatusUtils): AIContainers { + getImageTag(recipe: Recipe, container: ContainerConfig) { + let tag = container.image ?? `${recipe.id}-${container.name}`; + if (!tag.includes(':')) { + tag += ':latest'; + } + return tag; + } + + getConfigAndFilterContainers( + recipeConfig: string, + localFolder: string, + labels?: { [key: string]: string }, + ): AIContainers { // Adding loading configuration task - const loadingConfiguration: Task = { - id: 'loading-config', - name: 'Loading configuration', - state: 'loading', - }; - taskUtil.setTask(loadingConfiguration); + const task = this.taskRegistry.createTask('Loading configuration', 'loading', labels); let aiConfigFile: AIConfigFile; try { // load and parse the recipe configuration file aiConfigFile = this.getConfiguration(recipeConfig, localFolder); } catch (e) { - loadingConfiguration.state = 'error'; - taskUtil.setTask(loadingConfiguration); + task.error = `Something went wrong while loading configuration: ${String(e)}.`; + this.taskRegistry.updateTask(task); throw e; } @@ -325,12 +497,12 @@ export class ApplicationManager { const filteredContainers: ContainerConfig[] = this.filterContainers(aiConfigFile.aiConfig); if (filteredContainers.length > 0) { // Mark as success. - loadingConfiguration.state = 'success'; - taskUtil.setTask(loadingConfiguration); + task.state = 'success'; + this.taskRegistry.updateTask(task); } else { // Mark as failure. - loadingConfiguration.state = 'error'; - taskUtil.setTask(loadingConfiguration); + task.error = 'No containers available.'; + this.taskRegistry.updateTask(task); throw new Error('No containers available.'); } @@ -342,36 +514,10 @@ export class ApplicationManager { filterContainers(aiConfig: AIConfig): ContainerConfig[] { return aiConfig.application.containers.filter( - container => container.arch === undefined || container.arch === arch(), + container => container.gpu_env.length === 0 && container.arch.some(arc => arc === goarch()), ); } - async downloadModel(model: ModelInfo, taskUtil: RecipeStatusUtils) { - if (!this.modelsManager.isModelOnDisk(model.id)) { - // Download model - taskUtil.setTask({ - id: model.id, - state: 'loading', - name: `Downloading model ${model.name}`, - labels: { - 'model-pulling': model.id, - }, - }); - - return await this.doDownloadModelWrapper(model.id, model.url, taskUtil); - } else { - taskUtil.setTask({ - id: model.id, - state: 'success', - name: `Model ${model.name} already present on disk`, - labels: { - 'model-pulling': model.id, - }, - }); - return this.modelsManager.getLocalModelPath(model.id); - } - } - getConfiguration(recipeConfig: string, localFolder: string): AIConfigFile { let configFile: string; if (recipeConfig !== undefined) { @@ -395,12 +541,12 @@ export class ApplicationManager { // Parsing the configuration console.log(`Reading configuration from ${configFile}.`); - const rawConfiguration = fs.readFileSync(configFile, 'utf-8'); let aiConfig: AIConfig; try { - aiConfig = parseYaml(rawConfiguration, arch()); + aiConfig = parseYamlFile(configFile, goarch()); } catch (err) { - throw new Error('Cannot load configuration file.'); + console.error('Cannot load configure file.', err); + throw new Error(`Cannot load configuration file.`); } // Mark as success. @@ -410,121 +556,259 @@ export class ApplicationManager { }; } - async doCheckout(repository: string, localFolder: string, taskUtil: RecipeStatusUtils) { - // Adding checkout task - const checkoutTask: Task = { - id: 'checkout', - name: 'Checkout repository', - state: 'loading', - labels: { - git: 'checkout', - }, - }; - taskUtil.setTask(checkoutTask); - - // We might already have the repository cloned - if (fs.existsSync(localFolder) && fs.statSync(localFolder).isDirectory()) { - // Update checkout state - checkoutTask.name = 'Checkout repository (cached).'; - checkoutTask.state = 'success'; - } else { - // Create folder - fs.mkdirSync(localFolder, { recursive: true }); + async doCheckout(gitCloneInfo: GitCloneInfo, labels?: { [id: string]: string }): Promise { + // Creating checkout task + const checkoutTask: Task = this.taskRegistry.createTask('Checking out repository', 'loading', { + ...labels, + git: 'checkout', + }); - // Clone the repository - console.log(`Cloning repository ${repository} in ${localFolder}.`); - await this.git.cloneRepository(repository, localFolder); + const installed = await this.git.isGitInstalled(); + if (!installed) { + checkoutTask.state = 'error'; + checkoutTask.error = 'Git is not installed or cannot be found.'; + this.taskRegistry.updateTask(checkoutTask); + // propagate error + throw new Error(checkoutTask.error); + } - // Update checkout state + try { + await this.git.processCheckout(gitCloneInfo); checkoutTask.state = 'success'; + } catch (err: unknown) { + checkoutTask.state = 'error'; + checkoutTask.error = String(err); + // propagate error + throw err; + } finally { + // Update task registry + this.taskRegistry.updateTask(checkoutTask); } - // Update task - taskUtil.setTask(checkoutTask); } - doDownloadModelWrapper( - modelId: string, - url: string, - taskUtil: RecipeStatusUtils, - destFileName?: string, - ): Promise { - return new Promise((resolve, reject) => { - const downloadCallback = (result: DownloadModelResult) => { - if (result.result) { - taskUtil.setTaskState(modelId, 'success'); - resolve(destFileName); - } else { - taskUtil.setTaskState(modelId, 'error'); - reject(result.error); - } - }; - - if (fs.existsSync(destFileName)) { - taskUtil.setTaskState(modelId, 'success'); - taskUtil.setTaskProgress(modelId, 100); + adoptRunningApplications() { + this.podmanConnection.startupSubscribe(() => { + if (!containerEngine.listPods) { + // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released + // and the extension minimal version is set to 1.8 return; } - - this.doDownloadModel(modelId, url, taskUtil, downloadCallback, destFileName); + containerEngine + .listPods() + .then(pods => { + const appsPods = pods.filter(pod => LABEL_RECIPE_ID in pod.Labels); + for (const podToAdopt of appsPods) { + this.adoptPod(podToAdopt); + } + }) + .catch((err: unknown) => { + console.error('error during adoption of existing playground containers', err); + }); }); - } - private doDownloadModel( - modelId: string, - url: string, - taskUtil: RecipeStatusUtils, - callback: (message: DownloadModelResult) => void, - destFileName?: string, - ) { - const destDir = path.join(this.appUserDirectory, 'models', modelId); - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - if (!destFileName) { - destFileName = path.basename(url); - } - const destFile = path.resolve(destDir, destFileName); - const file = fs.createWriteStream(destFile); - let totalFileSize = 0; - let progress = 0; - https.get(url, resp => { - if (resp.headers.location) { - this.doDownloadModel(modelId, resp.headers.location, taskUtil, callback, destFileName); - return; - } else { - if (totalFileSize === 0 && resp.headers['content-length']) { - totalFileSize = parseFloat(resp.headers['content-length']); - } + this.podmanConnection.onMachineStop(() => { + // Podman Machine has been stopped, we consider all recipe pods are stopped + for (const recipeModelIndex of this.#applications.keys()) { + this.taskRegistry.createTask('AI App stopped manually', 'success', { + 'recipe-id': recipeModelIndex.recipeId, + 'model-id': recipeModelIndex.modelId, + }); } - let previousProgressValue = -1; - resp.on('data', chunk => { - progress += chunk.length; - const progressValue = (progress * 100) / totalFileSize; + this.#applications.clear(); + this.notify(); + }); - if (progressValue === 100 || progressValue - previousProgressValue > 1) { - previousProgressValue = progressValue; - taskUtil.setTaskProgress(modelId, progressValue); - } + this.podmanConnection.onPodStart((pod: PodInfo) => { + this.adoptPod(pod); + }); + this.podmanConnection.onPodStop((pod: PodInfo) => { + this.forgetPod(pod); + }); + this.podmanConnection.onPodRemove((podId: string) => { + this.forgetPodById(podId); + }); + } - // send progress in percentage (ex. 1.2%, 2.6%, 80.1%) to frontend - //this.sendProgress(progressValue); - if (progressValue === 100) { - callback({ - result: 'ok', - }); - } - }); - file.on('finish', () => { - file.close(); + adoptPod(pod: PodInfo) { + if (!pod.Labels) { + return; + } + const recipeId = pod.Labels[LABEL_RECIPE_ID]; + const modelId = pod.Labels[LABEL_MODEL_ID]; + const appPorts = this.getPortsFromLabel(pod.Labels, LABEL_APP_PORTS); + const modelPorts = this.getPortsFromLabel(pod.Labels, LABEL_MODEL_PORTS); + if (this.#applications.has({ recipeId, modelId })) { + return; + } + const state: ApplicationState = { + recipeId, + modelId, + pod, + appPorts, + modelPorts, + }; + this.updateApplicationState(recipeId, modelId, state); + } + + forgetPod(pod: PodInfo) { + if (!pod.Labels) { + return; + } + const recipeId = pod.Labels[LABEL_RECIPE_ID]; + const modelId = pod.Labels[LABEL_MODEL_ID]; + if (!this.#applications.has({ recipeId, modelId })) { + return; + } + this.#applications.delete({ recipeId, modelId }); + this.notify(); + + const protect = this.protectTasks.has(pod.Id); + if (!protect) { + this.taskRegistry.createTask('AI App stopped manually', 'success', { + 'recipe-id': recipeId, + 'model-id': modelId, }); - file.on('error', e => { - callback({ - result: 'failed', - error: e.message, - }); + } else { + this.protectTasks.delete(pod.Id); + } + } + + forgetPodById(podId: string) { + const app = Array.from(this.#applications.values()).find(p => p.pod.Id === podId); + if (!app) { + return; + } + if (!app.pod.Labels) { + return; + } + const recipeId = app.pod.Labels[LABEL_RECIPE_ID]; + const modelId = app.pod.Labels[LABEL_MODEL_ID]; + if (!this.#applications.has({ recipeId, modelId })) { + return; + } + this.#applications.delete({ recipeId, modelId }); + this.notify(); + + const protect = this.protectTasks.has(podId); + if (!protect) { + this.taskRegistry.createTask('AI App stopped manually', 'success', { + 'recipe-id': recipeId, + 'model-id': modelId, }); - resp.pipe(file); + } else { + this.protectTasks.delete(podId); + } + } + + updateApplicationState(recipeId: string, modelId: string, state: ApplicationState): void { + this.#applications.set({ recipeId, modelId }, state); + this.notify(); + } + + getApplicationsState(): ApplicationState[] { + return Array.from(this.#applications.values()); + } + + async deleteApplication(recipeId: string, modelId: string) { + // clear any existing status / tasks related to the pair recipeId-modelId. + this.taskRegistry.deleteByLabels({ + 'recipe-id': recipeId, + 'model-id': modelId, + }); + + const stoppingTask = this.taskRegistry.createTask(`Stopping AI App`, 'loading', { + 'recipe-id': recipeId, + 'model-id': modelId, }); + try { + const appPod = await this.getApplicationPod(recipeId, modelId); + try { + await containerEngine.stopPod(appPod.engineId, appPod.Id); + } catch (err: unknown) { + // continue when the pod is already stopped + if (!String(err).includes('pod already stopped')) { + stoppingTask.error = 'error stopping the pod. Please try to stop and remove the pod manually'; + stoppingTask.name = 'Error stopping AI App'; + this.taskRegistry.updateTask(stoppingTask); + throw err; + } + } + this.protectTasks.add(appPod.Id); + await containerEngine.removePod(appPod.engineId, appPod.Id); + + stoppingTask.state = 'success'; + stoppingTask.name = `AI App stopped`; + } catch (err: unknown) { + stoppingTask.error = 'error removing the pod. Please try to remove the pod manually'; + stoppingTask.name = 'Error stopping AI App'; + throw err; + } finally { + this.taskRegistry.updateTask(stoppingTask); + } + } + + async restartApplication(recipeId: string, modelId: string) { + const appPod = await this.getApplicationPod(recipeId, modelId); + await this.deleteApplication(recipeId, modelId); + const recipe = this.catalogManager.getRecipeById(recipeId); + const model = this.catalogManager.getModelById(appPod.Labels[LABEL_MODEL_ID]); + await this.startApplication(recipe, model); + } + + async getApplicationPorts(recipeId: string, modelId: string): Promise { + const recipe = this.catalogManager.getRecipeById(recipeId); + const state = this.#applications.get({ recipeId, modelId }); + if (state) { + return state.appPorts; + } + throw new Error(`Recipe ${recipe.name} has no ports available`); + } + + async getApplicationPod(recipeId: string, modelId: string): Promise { + const appPod = await this.queryPod(recipeId, modelId); + if (!appPod) { + throw new Error(`no pod found with recipe Id ${recipeId} and model Id ${modelId}`); + } + return appPod; + } + + async hasApplicationPod(recipeId: string, modelId: string): Promise { + const appPod = await this.queryPod(recipeId, modelId); + return !!appPod; + } + + async queryPod(recipeId: string, modelId: string): Promise { + if (!containerEngine.listPods || !containerEngine.stopPod || !containerEngine.removePod) { + // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released + // and the extension minimal version is set to 1.8 + return; + } + const pods = await containerEngine.listPods(); + return pods.find( + pod => + LABEL_RECIPE_ID in pod.Labels && + pod.Labels[LABEL_RECIPE_ID] === recipeId && + LABEL_MODEL_ID in pod.Labels && + pod.Labels[LABEL_MODEL_ID] === modelId, + ); + } + + getPortsFromLabel(labels: { [key: string]: string }, key: string): number[] { + if (!(key in labels)) { + return []; + } + const value = labels[key]; + const portsStr = value.split(','); + const result: number[] = []; + for (const portStr of portsStr) { + const port = parseInt(portStr, 10); + if (isNaN(port)) { + // malformed label, just ignore it + return []; + } + result.push(port); + } + return result; } } diff --git a/packages/backend/src/managers/catalogManager.spec.ts b/packages/backend/src/managers/catalogManager.spec.ts new file mode 100644 index 000000000..2ad2cdddb --- /dev/null +++ b/packages/backend/src/managers/catalogManager.spec.ts @@ -0,0 +1,142 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import content from '../tests/ai-test.json'; +import userContent from '../tests/ai-user-test.json'; +import { type Webview, EventEmitter } from '@podman-desktop/api'; +import { CatalogManager } from './catalogManager'; + +import * as fs from 'node:fs'; + +vi.mock('./ai.json', () => { + return { + default: content, + }; +}); + +vi.mock('node:fs', () => { + return { + existsSync: vi.fn(), + promises: { + readFile: vi.fn(), + }, + }; +}); + +const mocks = vi.hoisted(() => ({ + withProgressMock: vi.fn(), +})); + +vi.mock('@podman-desktop/api', async () => { + return { + EventEmitter: vi.fn(), + window: { + withProgress: mocks.withProgressMock, + }, + ProgressLocation: { + TASK_WIDGET: 'TASK_WIDGET', + }, + fs: { + createFileSystemWatcher: () => ({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + onDidChange: vi.fn(), + }), + }, + }; +}); + +let catalogManager: CatalogManager; + +beforeEach(async () => { + vi.resetAllMocks(); + + const appUserDirectory = '.'; + // Creating CatalogManager + catalogManager = new CatalogManager( + { + postMessage: vi.fn().mockResolvedValue(undefined), + } as unknown as Webview, + appUserDirectory, + ); + + vi.mock('node:fs'); + + const listeners: ((value: unknown) => void)[] = []; + + vi.mocked(EventEmitter).mockReturnValue({ + event: vi.fn().mockImplementation(callback => { + listeners.push(callback); + }), + fire: vi.fn().mockImplementation((content: unknown) => { + listeners.forEach(listener => listener(content)); + }), + } as unknown as EventEmitter); +}); + +describe('invalid user catalog', () => { + beforeEach(async () => { + vi.spyOn(fs.promises, 'readFile').mockResolvedValue('invalid json'); + catalogManager.init(); + }); + + test('expect correct model is returned with valid id', () => { + const model = catalogManager.getModelById('hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M'); + expect(model).toBeDefined(); + expect(model.name).toEqual('TheBloke/Mistral-7B-Instruct-v0.1-GGUF'); + expect(model.registry).toEqual('Hugging Face'); + expect(model.url).toEqual( + 'https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf', + ); + }); + + test('expect error if id does not correspond to any model', () => { + expect(() => catalogManager.getModelById('unknown')).toThrowError('No model found having id unknown'); + }); +}); + +test('expect correct model is returned from default catalog with valid id when no user catalog exists', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + catalogManager.init(); + await vi.waitUntil(() => catalogManager.getRecipes().length > 0); + + const model = catalogManager.getModelById('hf.TheBloke.mistral-7b-instruct-v0.1.Q4_K_M'); + expect(model).toBeDefined(); + expect(model.name).toEqual('TheBloke/Mistral-7B-Instruct-v0.1-GGUF'); + expect(model.registry).toEqual('Hugging Face'); + expect(model.url).toEqual( + 'https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.1-GGUF/resolve/main/mistral-7b-instruct-v0.1.Q4_K_M.gguf', + ); +}); + +test('expect correct model is returned with valid id when the user catalog is valid', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs.promises, 'readFile').mockResolvedValue(JSON.stringify(userContent)); + + catalogManager.init(); + await vi.waitUntil(() => catalogManager.getRecipes().length > 0); + + const model = catalogManager.getModelById('model1'); + expect(model).toBeDefined(); + expect(model.name).toEqual('Model 1'); + expect(model.registry).toEqual('Hugging Face'); + expect(model.url).toEqual('https://model1.example.com'); +}); diff --git a/packages/backend/src/managers/catalogManager.ts b/packages/backend/src/managers/catalogManager.ts index db1e390bd..4b6c1b23b 100644 --- a/packages/backend/src/managers/catalogManager.ts +++ b/packages/backend/src/managers/catalogManager.ts @@ -16,105 +16,81 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { Catalog } from '@shared/src/models/ICatalog'; +import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog'; import path from 'node:path'; -import { existsSync, promises } from 'node:fs'; -import defaultCatalog from '../ai.json'; -import type { Category } from '@shared/src/models/ICategory'; +import defaultCatalog from '../assets/ai.json'; import type { Recipe } from '@shared/src/models/IRecipe'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; -import { MSG_NEW_CATALOG_STATE } from '@shared/Messages'; -import { fs } from '@podman-desktop/api'; -import type { Webview } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; +import { type Disposable, type Webview } from '@podman-desktop/api'; +import { JsonWatcher } from '../utils/JsonWatcher'; +import { Publisher } from '../utils/Publisher'; -export class CatalogManager { - private catalog: Catalog; +export class CatalogManager extends Publisher implements Disposable { + private catalog: ApplicationCatalog; + #disposables: Disposable[]; constructor( + webview: Webview, private appUserDirectory: string, - private webview: Webview, ) { + super(webview, Messages.MSG_NEW_CATALOG_STATE, () => this.getCatalog()); // We start with an empty catalog, for the methods to work before the catalog is loaded this.catalog = { categories: [], models: [], recipes: [], }; - } - public getCatalog(): Catalog { - return this.catalog; + this.#disposables = []; } - public getCategories(): Category[] { - return this.catalog.categories; - } + init(): void { + // Creating a json watcher + const jsonWatcher: JsonWatcher = new JsonWatcher( + path.resolve(this.appUserDirectory, 'catalog.json'), + defaultCatalog, + ); + jsonWatcher.onContentUpdated(content => this.onCatalogUpdated(content)); + jsonWatcher.init(); - public getModels(): ModelInfo[] { - return this.catalog.models; - } - public getRecipes(): Recipe[] { - return this.catalog.recipes; + this.#disposables.push(jsonWatcher); } - async loadCatalog() { - const catalogPath = path.resolve(this.appUserDirectory, 'catalog.json'); + private onCatalogUpdated(content: ApplicationCatalog): void { + this.catalog = content; + this.notify(); + } - try { - this.watchCatalogFile(catalogPath); // do not await, we want to do this async - } catch (err: unknown) { - console.error(`unable to watch catalog file, changes to the catalog file won't be reflected to the UI`, err); - } + dispose(): void { + this.#disposables.forEach(watcher => watcher.dispose()); + } - if (!existsSync(catalogPath)) { - return this.setCatalog(defaultCatalog); - } + public getCatalog(): ApplicationCatalog { + return this.catalog; + } - try { - const cat = await this.readAndAnalyzeCatalog(catalogPath); - return this.setCatalog(cat); - } catch (err: unknown) { - console.error('unable to read catalog file, reverting to default catalog', err); - } - // If something went wrong we load the default catalog - return this.setCatalog(defaultCatalog); + public getModels(): ModelInfo[] { + return this.catalog.models; } - watchCatalogFile(path: string) { - const watcher = fs.createFileSystemWatcher(path); - watcher.onDidCreate(async () => { - try { - const cat = await this.readAndAnalyzeCatalog(path); - await this.setCatalog(cat); - } catch (err: unknown) { - console.error('unable to read created catalog file, continue using default catalog', err); - } - }); - watcher.onDidDelete(async () => { - console.log('user catalog file deleted, reverting to default catalog'); - await this.setCatalog(defaultCatalog); - }); - watcher.onDidChange(async () => { - try { - const cat = await this.readAndAnalyzeCatalog(path); - await this.setCatalog(cat); - } catch (err: unknown) { - console.error('unable to read modified catalog file, reverting to default catalog', err); - } - }); + public getModelById(modelId: string): ModelInfo { + const model = this.getModels().find(m => modelId === m.id); + if (!model) { + throw new Error(`No model found having id ${modelId}`); + } + return model; } - async readAndAnalyzeCatalog(path: string): Promise { - const data = await promises.readFile(path, 'utf-8'); - return JSON.parse(data) as Catalog; - // TODO(feloy): check version, ... + public getRecipes(): Recipe[] { + return this.catalog.recipes; } - async setCatalog(newCatalog: Catalog) { - this.catalog = newCatalog; - await this.webview.postMessage({ - id: MSG_NEW_CATALOG_STATE, - body: this.catalog, - }); + public getRecipeById(recipeId: string): Recipe { + const recipe = this.getRecipes().find(r => recipeId === r.id); + if (!recipe) { + throw new Error(`No recipe found having id ${recipeId}`); + } + return recipe; } } diff --git a/packages/backend/src/managers/gitManager.spec.ts b/packages/backend/src/managers/gitManager.spec.ts new file mode 100644 index 000000000..cf79e9328 --- /dev/null +++ b/packages/backend/src/managers/gitManager.spec.ts @@ -0,0 +1,299 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { GitManager } from './gitManager'; +import { statSync, existsSync, mkdirSync, type Stats, rmSync } from 'node:fs'; +import { window } from '@podman-desktop/api'; + +const mocks = vi.hoisted(() => { + return { + cloneMock: vi.fn(), + checkoutMock: vi.fn(), + versionMock: vi.fn(), + getRemotesMock: vi.fn(), + statusMock: vi.fn(), + pullMock: vi.fn(), + revparseMock: vi.fn(), + fetchMock: vi.fn(), + }; +}); + +vi.mock('node:fs', () => { + return { + existsSync: vi.fn(), + statSync: vi.fn(), + mkdirSync: vi.fn(), + rmSync: vi.fn(), + }; +}); + +vi.mock('simple-git', () => { + return { + default: () => ({ + clone: mocks.cloneMock, + checkout: mocks.checkoutMock, + version: mocks.versionMock, + getRemotes: mocks.getRemotesMock, + status: mocks.statusMock, + pull: mocks.pullMock, + revparse: mocks.revparseMock, + fetch: mocks.fetchMock, + }), + }; +}); + +vi.mock('@podman-desktop/api', async () => { + return { + window: { + showWarningMessage: vi.fn(), + }, + }; +}); + +beforeEach(() => { + vi.resetAllMocks(); + + mocks.revparseMock.mockResolvedValue('dummyCommit'); +}); + +describe('cloneRepository', () => { + const gitmanager = new GitManager(); + test('clone and checkout if ref is specified', async () => { + await gitmanager.cloneRepository({ + repository: 'repo', + targetDirectory: 'target', + ref: '000', + }); + expect(mocks.cloneMock).toBeCalledWith('repo', 'target'); + expect(mocks.checkoutMock).toBeCalledWith(['000']); + }); + test('clone and checkout if ref is NOT specified', async () => { + await gitmanager.cloneRepository({ + repository: 'repo', + targetDirectory: 'target', + }); + expect(mocks.cloneMock).toBeCalledWith('repo', 'target'); + expect(mocks.checkoutMock).not.toBeCalled(); + }); +}); + +describe('processCheckout', () => { + test('first install no existing folder', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + await new GitManager().processCheckout({ + repository: 'repo', + targetDirectory: 'target', + ref: '000', + }); + + expect(existsSync).toHaveBeenCalledWith('target'); + expect(mkdirSync).toHaveBeenCalledWith('target', { recursive: true }); + expect(mocks.cloneMock).toHaveBeenCalledWith('repo', 'target'); + }); + + test('existing folder valid', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'isRepositoryUpToDate').mockResolvedValue({ ok: true }); + + await gitmanager.processCheckout({ + repository: 'repo', + targetDirectory: 'target', + ref: '000', + }); + + expect(gitmanager.isRepositoryUpToDate).toHaveBeenCalled(); + expect(existsSync).toHaveBeenCalledWith('target'); + expect(statSync).toHaveBeenCalledWith('target'); + + expect(mkdirSync).not.toHaveBeenCalled(); + expect(mocks.cloneMock).not.toHaveBeenCalled(); + }); + + test('existing folder detached and user cancel', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(window.showWarningMessage).mockResolvedValue('Cancel'); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'isRepositoryUpToDate').mockResolvedValue({ ok: false, updatable: false }); + + await expect( + gitmanager.processCheckout({ + repository: 'repo', + targetDirectory: 'target', + ref: '000', + }), + ).rejects.toThrowError('Cancelled'); + }); + + test('existing folder not-updatable and user continue', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(window.showWarningMessage).mockResolvedValue('Continue'); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'isRepositoryUpToDate').mockResolvedValue({ ok: false, updatable: false }); + + await gitmanager.processCheckout({ + repository: 'repo', + targetDirectory: 'target', + ref: '000', + }); + + expect(rmSync).not.toHaveBeenCalled(); + expect(mkdirSync).not.toHaveBeenCalled(); + expect(mocks.cloneMock).not.toHaveBeenCalled(); + }); + + test('existing folder not-updatable and user reset', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(window.showWarningMessage).mockResolvedValue('Reset'); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'isRepositoryUpToDate').mockResolvedValue({ ok: false, updatable: false }); + + await gitmanager.processCheckout({ + repository: 'repo', + targetDirectory: 'target', + ref: '000', + }); + + expect(window.showWarningMessage).toHaveBeenCalledWith(expect.anything(), 'Cancel', 'Continue', 'Reset'); + expect(rmSync).toHaveBeenCalledWith('target', { recursive: true }); + }); + + test('existing folder updatable and user update', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(window.showWarningMessage).mockResolvedValue('Update'); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'isRepositoryUpToDate').mockResolvedValue({ ok: false, updatable: true }); + vi.spyOn(gitmanager, 'pull').mockResolvedValue(undefined); + + await gitmanager.processCheckout({ + repository: 'repo', + targetDirectory: 'target', + ref: '000', + }); + + expect(window.showWarningMessage).toHaveBeenCalledWith(expect.anything(), 'Cancel', 'Continue', 'Update'); + expect(rmSync).not.toHaveBeenCalled(); + expect(gitmanager.pull).toHaveBeenCalled(); + }); +}); + +describe('isRepositoryUpToDate', () => { + test('detached invalid without ref', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'getRepositoryRemotes').mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'repo', + push: 'repo', + }, + }, + ]); + mocks.statusMock.mockResolvedValue({ + detached: true, + }); + + const result = await gitmanager.isRepositoryUpToDate('target', 'repo', undefined); + expect(result.ok).toBeFalsy(); + expect(result.error).toBe('The local repository is detached.'); + }); + + test('detached invalid with invalid ref', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'getRepositoryRemotes').mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'repo', + push: 'repo', + }, + }, + ]); + mocks.statusMock.mockResolvedValue({ + detached: true, + }); + + const result = await gitmanager.isRepositoryUpToDate('target', 'repo', 'invalidRef'); + expect(result.ok).toBeFalsy(); + expect(result.error).toBe('The local repository is detached. HEAD is dummyCommit expected invalidRef.'); + }); + + test('detached invalid with expected ref', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as Stats); + + const gitmanager = new GitManager(); + + vi.spyOn(gitmanager, 'getRepositoryRemotes').mockResolvedValue([ + { + name: 'origin', + refs: { + fetch: 'repo', + push: 'repo', + }, + }, + ]); + mocks.statusMock.mockResolvedValue({ + detached: true, + }); + + const result = await gitmanager.isRepositoryUpToDate('target', 'repo', 'dummyCommit'); + expect(result.ok).toBeTruthy(); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/managers/gitManager.ts b/packages/backend/src/managers/gitManager.ts index 919e232ac..f814f014f 100644 --- a/packages/backend/src/managers/gitManager.ts +++ b/packages/backend/src/managers/gitManager.ts @@ -16,15 +16,153 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import simpleGit, { type SimpleGit } from 'simple-git'; +import simpleGit, { type PullResult, type RemoteWithRefs, type StatusResult } from 'simple-git'; +import { window } from '@podman-desktop/api'; +import { statSync, existsSync, mkdirSync, rmSync } from 'node:fs'; + +export interface GitCloneInfo { + repository: string; + ref?: string; + targetDirectory: string; +} export class GitManager { - private readonly simpleGit: SimpleGit; - constructor() { - this.simpleGit = simpleGit(); + async cloneRepository(gitCloneInfo: GitCloneInfo) { + // clone repo + await simpleGit().clone(gitCloneInfo.repository, gitCloneInfo.targetDirectory); + // checkout to specific branch/commit if specified + if (gitCloneInfo.ref) { + await simpleGit(gitCloneInfo.targetDirectory).checkout([gitCloneInfo.ref]); + } + } + + async getRepositoryRemotes(directory: string): Promise { + return simpleGit(directory).getRemotes(true); + } + + async getRepositoryStatus(directory: string): Promise { + return simpleGit(directory).status(); + } + + async getCurrentCommit(directory: string): Promise { + return simpleGit(directory).revparse('HEAD'); + } + + async pull(directory: string): Promise { + const pullResult: PullResult = await simpleGit(directory).pull(); + console.debug(`local git repository updated. ${pullResult.summary.changes} changes applied`); } - async cloneRepository(repository: string, targetDirectory: string) { - return this.simpleGit.clone(repository, targetDirectory); + async isGitInstalled(): Promise { + try { + const version = await simpleGit().version(); + return version.installed; + } catch (err: unknown) { + console.error(`Something went wrong while trying to access git: ${String(err)}`); + return false; + } + } + + async processCheckout(gitCloneInfo: GitCloneInfo): Promise { + // Check for existing cloned repository + if (existsSync(gitCloneInfo.targetDirectory) && statSync(gitCloneInfo.targetDirectory).isDirectory()) { + const result = await this.isRepositoryUpToDate( + gitCloneInfo.targetDirectory, + gitCloneInfo.repository, + gitCloneInfo.ref, + ); + + if (result.ok) { + return; + } + + const error = `The repository "${gitCloneInfo.repository}" appears to have already been cloned and does not match the expected configuration: ${result.error}`; + + // Ask user + const selected = await window.showWarningMessage( + `${error} By continuing, the AI application may not run as expected. `, + 'Cancel', + 'Continue', + result.updatable ? 'Update' : 'Reset', + ); + + switch (selected) { + case undefined: + case 'Cancel': + throw new Error('Cancelled'); + case 'Continue': + return; + case 'Update': + await this.pull(gitCloneInfo.targetDirectory); + return; + case 'Reset': + rmSync(gitCloneInfo.targetDirectory, { recursive: true }); + break; + } + } + + // Create folder + mkdirSync(gitCloneInfo.targetDirectory, { recursive: true }); + + // Clone the repository + console.log(`Cloning repository ${gitCloneInfo.repository} in ${gitCloneInfo.targetDirectory}.`); + await this.cloneRepository(gitCloneInfo); + } + + async isRepositoryUpToDate( + directory: string, + origin: string, + ref?: string, + ): Promise<{ ok?: boolean; updatable?: boolean; error?: string }> { + // fetch updates + await simpleGit(directory).fetch(); + + const remotes: RemoteWithRefs[] = await this.getRepositoryRemotes(directory); + + if (!remotes.some(remote => remote.refs.fetch === origin)) { + return { + error: `The local repository does not have remote ${origin} configured. Remotes: ${remotes + .map(remote => `${remote.name} ${remote.refs.fetch} (fetch)`) + .join(',')}`, + }; + } + + const status: StatusResult = await this.getRepositoryStatus(directory); + + let error: string | undefined; + + if (!remotes.some(remote => remote.refs.fetch === origin)) { + error = `The local repository does not have remote ${origin} configured. Remotes: ${remotes + .map(remote => `${remote.name} ${remote.refs.fetch} (fetch)`) + .join(',')}`; + } else if (status.detached) { + // when the repository is detached + if (ref === undefined) { + error = 'The local repository is detached.'; + } else { + const commit = await this.getCurrentCommit(directory); + if (!commit.startsWith(ref)) error = `The local repository is detached. HEAD is ${commit} expected ${ref}.`; + } + } else if (status.modified.length > 0) { + error = 'The local repository has modified files.'; + } else if (status.created.length > 0) { + error = 'The local repository has created files.'; + } else if (status.deleted.length > 0) { + error = 'The local repository has created files.'; + } else if (status.ahead !== 0) { + error = `The local repository has ${status.ahead} commit(s) ahead.`; + } else if (ref !== undefined && status.tracking !== ref) { + error = `The local repository is not tracking the right branch. (tracking ${status.tracking} when expected ${ref})`; + } else if (!status.isClean()) { + error = 'The local repository is not clean.'; + } else if (status.behind !== 0) { + return { ok: true, updatable: true }; + } + + if (error) { + return { error }; + } + + return { ok: true }; // If none of the error conditions are met } } diff --git a/packages/backend/src/managers/inference/inferenceManager.spec.ts b/packages/backend/src/managers/inference/inferenceManager.spec.ts new file mode 100644 index 000000000..6381a4fe1 --- /dev/null +++ b/packages/backend/src/managers/inference/inferenceManager.spec.ts @@ -0,0 +1,538 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { + containerEngine, + provider, + type Webview, + type TelemetryLogger, + type ImageInfo, + type ContainerInfo, + type ContainerInspectInfo, + type ProviderContainerConnection, +} from '@podman-desktop/api'; +import type { ContainerRegistry } from '../../registries/ContainerRegistry'; +import type { PodmanConnection } from '../podmanConnection'; +import { beforeEach, expect, describe, test, vi } from 'vitest'; +import { InferenceManager } from './inferenceManager'; +import type { ModelsManager } from '../modelsManager'; +import { LABEL_INFERENCE_SERVER, INFERENCE_SERVER_IMAGE } from '../../utils/inferenceUtils'; +import type { InferenceServerConfig } from '@shared/src/models/InferenceServerConfig'; +import type { TaskRegistry } from '../../registries/TaskRegistry'; + +vi.mock('@podman-desktop/api', async () => { + return { + containerEngine: { + startContainer: vi.fn(), + stopContainer: vi.fn(), + listContainers: vi.fn(), + inspectContainer: vi.fn(), + pullImage: vi.fn(), + listImages: vi.fn(), + createContainer: vi.fn(), + deleteContainer: vi.fn(), + }, + Disposable: { + from: vi.fn(), + create: vi.fn(), + }, + provider: { + getContainerConnections: vi.fn(), + }, + }; +}); + +const webviewMock = { + postMessage: vi.fn(), +} as unknown as Webview; + +const containerRegistryMock = { + onStartContainerEvent: vi.fn(), + subscribe: vi.fn(), +} as unknown as ContainerRegistry; + +const podmanConnectionMock = { + onMachineStart: vi.fn(), + onMachineStop: vi.fn(), +} as unknown as PodmanConnection; + +const modelsManager = { + getLocalModelPath: vi.fn(), + uploadModelToPodmanMachine: vi.fn(), +} as unknown as ModelsManager; + +const telemetryMock = { + logUsage: vi.fn(), + logError: vi.fn(), +} as unknown as TelemetryLogger; + +const taskRegistryMock = { + createTask: vi.fn(), + updateTask: vi.fn(), + getTasksByLabels: vi.fn(), +} as unknown as TaskRegistry; + +const getInitializedInferenceManager = async (): Promise => { + const manager = new InferenceManager( + webviewMock, + containerRegistryMock, + podmanConnectionMock, + modelsManager, + telemetryMock, + taskRegistryMock, + ); + manager.init(); + await vi.waitUntil(manager.isInitialize.bind(manager), { + interval: 200, + timeout: 2000, + }); + return manager; +}; + +const mockListContainers = (containers: Partial[]): void => { + vi.mocked(containerEngine.listContainers).mockResolvedValue(containers as unknown as ContainerInfo[]); +}; + +beforeEach(() => { + vi.resetAllMocks(); + // Default listContainers is empty + mockListContainers([]); + vi.mocked(webviewMock.postMessage).mockResolvedValue(undefined); + vi.mocked(containerEngine.inspectContainer).mockResolvedValue({ + State: { + Status: 'running', + Health: undefined, + }, + } as unknown as ContainerInspectInfo); + vi.mocked(provider.getContainerConnections).mockReturnValue([ + { + providerId: 'test@providerId', + connection: { + type: 'podman', + name: 'test@connection', + status: () => 'started', + }, + } as unknown as ProviderContainerConnection, + ]); + vi.mocked(containerEngine.listImages).mockResolvedValue([ + { + Id: 'dummyImageId', + engineId: 'dummyEngineId', + RepoTags: [INFERENCE_SERVER_IMAGE], + }, + ] as unknown as ImageInfo[]); + vi.mocked(containerEngine.createContainer).mockResolvedValue({ + id: 'dummyCreatedContainerId', + }); + vi.mocked(taskRegistryMock.getTasksByLabels).mockReturnValue([]); + vi.mocked(modelsManager.getLocalModelPath).mockReturnValue('/local/model.guff'); + vi.mocked(modelsManager.uploadModelToPodmanMachine).mockResolvedValue('/mnt/path/model.guff'); +}); + +/** + * Testing the initialization of the manager + */ +describe('init Inference Manager', () => { + test('should be initialized', async () => { + const inferenceManager = await getInitializedInferenceManager(); + expect(inferenceManager.isInitialize()).toBeTruthy(); + }); + + test('should have listed containers', async () => { + const inferenceManager = await getInitializedInferenceManager(); + + expect(inferenceManager.isInitialize()).toBeTruthy(); + expect(containerEngine.listContainers).toHaveBeenCalled(); + }); + + test('should ignore containers without the proper label', async () => { + mockListContainers([ + { + Id: 'dummyId', + }, + ]); + + const inferenceManager = await getInitializedInferenceManager(); + expect(inferenceManager.getServers().length).toBe(0); + }); + + test('should have adopted the existing container', async () => { + mockListContainers([ + { + Id: 'dummyContainerId', + engineId: 'dummyEngineId', + Labels: { + [LABEL_INFERENCE_SERVER]: '[]', + }, + }, + ]); + + const inferenceManager = await getInitializedInferenceManager(); + expect(inferenceManager.getServers()).toStrictEqual([ + { + connection: { + port: -1, + }, + container: { + containerId: 'dummyContainerId', + engineId: 'dummyEngineId', + }, + health: undefined, + models: [], + status: 'running', + }, + ]); + }); + + test('should have adopted all existing container with proper label', async () => { + mockListContainers([ + { + Id: 'dummyContainerId-1', + engineId: 'dummyEngineId-1', + Labels: { + [LABEL_INFERENCE_SERVER]: '[]', + }, + }, + { + Id: 'dummyContainerId-2', + engineId: 'dummyEngineId-2', + }, + { + Id: 'dummyContainerId-3', + engineId: 'dummyEngineId-3', + Labels: { + [LABEL_INFERENCE_SERVER]: '[]', + }, + }, + ]); + + const inferenceManager = await getInitializedInferenceManager(); + const servers = inferenceManager.getServers(); + expect(servers.length).toBe(2); + expect(servers.some(server => server.container.containerId === 'dummyContainerId-1')).toBeTruthy(); + expect(servers.some(server => server.container.containerId === 'dummyContainerId-3')).toBeTruthy(); + }); +}); + +/** + * Testing the creation logic + */ +describe('Create Inference Server', () => { + test('unknown providerId', async () => { + const inferenceManager = await getInitializedInferenceManager(); + await expect( + inferenceManager.createInferenceServer( + { + providerId: 'unknown', + } as unknown as InferenceServerConfig, + 'dummyTrackingId', + ), + ).rejects.toThrowError('cannot find any started container provider.'); + + expect(provider.getContainerConnections).toHaveBeenCalled(); + }); + + test('unknown imageId', async () => { + const inferenceManager = await getInitializedInferenceManager(); + await expect( + inferenceManager.createInferenceServer( + { + providerId: 'test@providerId', + image: 'unknown', + } as unknown as InferenceServerConfig, + 'dummyTrackingId', + ), + ).rejects.toThrowError('image unknown not found.'); + + expect(containerEngine.listImages).toHaveBeenCalled(); + }); + + test('empty modelsInfo', async () => { + const inferenceManager = await getInitializedInferenceManager(); + await expect( + inferenceManager.createInferenceServer( + { + providerId: 'test@providerId', + image: INFERENCE_SERVER_IMAGE, + modelsInfo: [], + } as unknown as InferenceServerConfig, + 'dummyTrackingId', + ), + ).rejects.toThrowError('Need at least one model info to start an inference server.'); + }); + + test('valid InferenceServerConfig', async () => { + const inferenceManager = await getInitializedInferenceManager(); + await inferenceManager.createInferenceServer( + { + port: 8888, + providerId: 'test@providerId', + image: INFERENCE_SERVER_IMAGE, + modelsInfo: [ + { + id: 'dummyModelId', + file: { + file: 'model.guff', + path: '/mnt/path', + }, + }, + ], + } as unknown as InferenceServerConfig, + 'dummyTrackingId', + ); + + expect(modelsManager.uploadModelToPodmanMachine).toHaveBeenCalledWith( + { + id: 'dummyModelId', + file: { + file: 'model.guff', + path: '/mnt/path', + }, + }, + { + trackingId: 'dummyTrackingId', + }, + ); + expect(taskRegistryMock.createTask).toHaveBeenNthCalledWith( + 1, + 'Pulling ghcr.io/projectatomic/ai-studio-playground-images/ai-studio-playground-chat:0.1.0.', + 'loading', + { + trackingId: 'dummyTrackingId', + }, + ); + expect(taskRegistryMock.createTask).toHaveBeenNthCalledWith(2, 'Creating container.', 'loading', { + trackingId: 'dummyTrackingId', + }); + expect(taskRegistryMock.updateTask).toHaveBeenLastCalledWith({ + state: 'success', + }); + expect(containerEngine.createContainer).toHaveBeenCalled(); + expect(inferenceManager.getServers()).toStrictEqual([ + { + connection: { + port: 8888, + }, + container: { + containerId: 'dummyCreatedContainerId', + engineId: 'dummyEngineId', + }, + models: [ + { + file: { + file: 'model.guff', + path: '/mnt/path', + }, + id: 'dummyModelId', + }, + ], + status: 'running', + }, + ]); + }); +}); + +/** + * Testing the starting logic + */ +describe('Start Inference Server', () => { + test('containerId unknown', async () => { + const inferenceManager = await getInitializedInferenceManager(); + await expect(inferenceManager.startInferenceServer('unknownContainerId')).rejects.toThrowError( + 'cannot find a corresponding server for container id unknownContainerId.', + ); + }); + + test('valid containerId', async () => { + mockListContainers([ + { + Id: 'dummyId', + engineId: 'dummyEngineId', + Labels: { + [LABEL_INFERENCE_SERVER]: '[]', + }, + }, + ]); + const inferenceManager = await getInitializedInferenceManager(); + await inferenceManager.startInferenceServer('dummyId'); + + expect(containerEngine.startContainer).toHaveBeenCalledWith('dummyEngineId', 'dummyId'); + + const servers = inferenceManager.getServers(); + expect(servers.length).toBe(1); + expect(servers[0].status).toBe('running'); + }); +}); + +/** + * Testing the stopping logic + */ +describe('Stop Inference Server', () => { + test('containerId unknown', async () => { + const inferenceManager = await getInitializedInferenceManager(); + await expect(inferenceManager.stopInferenceServer('unknownContainerId')).rejects.toThrowError( + 'cannot find a corresponding server for container id unknownContainerId.', + ); + }); + + test('valid containerId', async () => { + mockListContainers([ + { + Id: 'dummyId', + engineId: 'dummyEngineId', + Labels: { + [LABEL_INFERENCE_SERVER]: '[]', + }, + }, + ]); + const inferenceManager = await getInitializedInferenceManager(); + await inferenceManager.stopInferenceServer('dummyId'); + + expect(containerEngine.stopContainer).toHaveBeenCalledWith('dummyEngineId', 'dummyId'); + + const servers = inferenceManager.getServers(); + expect(servers.length).toBe(1); + expect(servers[0].status).toBe('stopped'); + }); +}); + +describe('Delete Inference Server', () => { + test('containerId unknown', async () => { + const inferenceManager = await getInitializedInferenceManager(); + await expect(inferenceManager.deleteInferenceServer('unknownContainerId')).rejects.toThrowError( + 'cannot find a corresponding server for container id unknownContainerId.', + ); + }); + + test('valid running containerId', async () => { + mockListContainers([ + { + Id: 'dummyId', + engineId: 'dummyEngineId', + Labels: { + [LABEL_INFERENCE_SERVER]: '[]', + }, + }, + ]); + const inferenceManager = await getInitializedInferenceManager(); + await inferenceManager.deleteInferenceServer('dummyId'); + + expect(containerEngine.stopContainer).toHaveBeenCalledWith('dummyEngineId', 'dummyId'); + expect(containerEngine.deleteContainer).toHaveBeenCalledWith('dummyEngineId', 'dummyId'); + + const servers = inferenceManager.getServers(); + expect(servers.length).toBe(0); + }); + + test('valid stopped containerId', async () => { + mockListContainers([ + { + Id: 'dummyId', + engineId: 'dummyEngineId', + Labels: { + [LABEL_INFERENCE_SERVER]: '[]', + }, + }, + ]); + vi.mocked(containerEngine.inspectContainer).mockResolvedValue({ + State: { + Status: 'stopped', + Health: undefined, + }, + } as unknown as ContainerInspectInfo); + + const inferenceManager = await getInitializedInferenceManager(); + await inferenceManager.deleteInferenceServer('dummyId'); + + expect(containerEngine.stopContainer).not.toHaveBeenCalled(); + expect(containerEngine.deleteContainer).toHaveBeenCalledWith('dummyEngineId', 'dummyId'); + + const servers = inferenceManager.getServers(); + expect(servers.length).toBe(0); + }); +}); + +describe('Request Create Inference Server', () => { + test('Should return unique string identifier', async () => { + const inferenceManager = await getInitializedInferenceManager(); + const identifier = inferenceManager.requestCreateInferenceServer({ + port: 8888, + providerId: 'test@providerId', + image: 'quay.io/bootsy/playground:v0', + modelsInfo: [ + { + id: 'dummyModelId', + file: { + file: 'dummyFile', + path: 'dummyPath', + }, + }, + ], + } as unknown as InferenceServerConfig); + expect(identifier).toBeDefined(); + expect(typeof identifier).toBe('string'); + }); + + test('Task registry should have tasks matching unique identifier provided', async () => { + const inferenceManager = await getInitializedInferenceManager(); + const identifier = inferenceManager.requestCreateInferenceServer({ + port: 8888, + providerId: 'test@providerId', + image: 'quay.io/bootsy/playground:v0', + modelsInfo: [ + { + id: 'dummyModelId', + file: { + file: 'dummyFile', + path: 'dummyPath', + }, + }, + ], + } as unknown as InferenceServerConfig); + + expect(taskRegistryMock.createTask).toHaveBeenNthCalledWith(1, 'Creating Inference server', 'loading', { + trackingId: identifier, + }); + }); + + test('Pull image error should be reflected in task registry', async () => { + vi.mocked(containerEngine.pullImage).mockRejectedValue(new Error('dummy pull image error')); + + const inferenceManager = await getInitializedInferenceManager(); + inferenceManager.requestCreateInferenceServer({ + port: 8888, + providerId: 'test@providerId', + image: 'quay.io/bootsy/playground:v0', + modelsInfo: [ + { + id: 'dummyModelId', + file: { + file: 'dummyFile', + path: 'dummyPath', + }, + }, + ], + } as unknown as InferenceServerConfig); + + await vi.waitFor(() => { + expect(taskRegistryMock.updateTask).toHaveBeenLastCalledWith({ + state: 'error', + error: 'Something went wrong while trying to create an inference server Error: dummy pull image error.', + }); + }); + }); +}); diff --git a/packages/backend/src/managers/inference/inferenceManager.ts b/packages/backend/src/managers/inference/inferenceManager.ts new file mode 100644 index 000000000..84821fce4 --- /dev/null +++ b/packages/backend/src/managers/inference/inferenceManager.ts @@ -0,0 +1,488 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { InferenceServer } from '@shared/src/models/IInference'; +import type { PodmanConnection } from '../podmanConnection'; +import { containerEngine, Disposable } from '@podman-desktop/api'; +import { + type ContainerInfo, + type ImageInfo, + type PullEvent, + type TelemetryLogger, + type Webview, +} from '@podman-desktop/api'; +import type { ContainerRegistry, ContainerStart } from '../../registries/ContainerRegistry'; +import { + generateContainerCreateOptions, + getImageInfo, + getProviderContainerConnection, + LABEL_INFERENCE_SERVER, +} from '../../utils/inferenceUtils'; +import { Publisher } from '../../utils/Publisher'; +import { Messages } from '@shared/Messages'; +import type { InferenceServerConfig } from '@shared/src/models/InferenceServerConfig'; +import type { ModelsManager } from '../modelsManager'; +import type { TaskRegistry } from '../../registries/TaskRegistry'; +import { getRandomString } from '../../utils/randomUtils'; +import { basename, dirname } from 'node:path'; + +export class InferenceManager extends Publisher implements Disposable { + // Inference server map (containerId -> InferenceServer) + #servers: Map; + // Is initialized + #initialized: boolean; + // Disposables + #disposables: Disposable[]; + + constructor( + webview: Webview, + private containerRegistry: ContainerRegistry, + private podmanConnection: PodmanConnection, + private modelsManager: ModelsManager, + private telemetry: TelemetryLogger, + private taskRegistry: TaskRegistry, + ) { + super(webview, Messages.MSG_INFERENCE_SERVERS_UPDATE, () => this.getServers()); + this.#servers = new Map(); + this.#disposables = []; + this.#initialized = false; + } + + init(): void { + this.podmanConnection.onMachineStart(this.watchMachineEvent.bind(this, 'start')); + this.podmanConnection.onMachineStop(this.watchMachineEvent.bind(this, 'stop')); + this.containerRegistry.onStartContainerEvent(this.watchContainerStart.bind(this)); + + this.retryableRefresh(3); + } + + public isInitialize(): boolean { + return this.#initialized; + } + + /** + * Cleanup the manager + */ + dispose(): void { + this.cleanDisposables(); + this.#servers.clear(); + this.#initialized = false; + } + + /** + * Clean class disposables + */ + private cleanDisposables(): void { + this.#disposables.forEach(disposable => disposable.dispose()); + } + + /** + * Get the Inference servers + */ + public getServers(): InferenceServer[] { + return Array.from(this.#servers.values()); + } + + /** + * return an inference server + * @param containerId the containerId of the inference server + */ + public get(containerId: string): InferenceServer | undefined { + return this.#servers.get(containerId); + } + + /** + * Creating an inference server can be heavy task (pulling image, uploading model to WSL etc.) + * The frontend cannot wait endlessly, therefore we provide a method returning a tracking identifier + * that can be used to fetch the tasks + * + * @param config the config to use to create the inference server + * + * @return a unique tracking identifier to follow the creation request + */ + requestCreateInferenceServer(config: InferenceServerConfig): string { + const trackingId: string = getRandomString(); + const task = this.taskRegistry.createTask('Creating Inference server', 'loading', { + trackingId: trackingId, + }); + + this.createInferenceServer(config, trackingId) + .then((containerId: string) => { + this.taskRegistry.updateTask({ + ...task, + state: 'success', + labels: { + ...task.labels, + containerId: containerId, + }, + }); + }) + .catch((err: unknown) => { + // Get all tasks using the tracker + const tasks = this.taskRegistry.getTasksByLabels({ + trackingId: trackingId, + }); + // Filter the one no in loading state + tasks + .filter(t => t.state === 'loading' && t.id !== task.id) + .forEach(t => { + this.taskRegistry.updateTask({ + ...t, + state: 'error', + }); + }); + // Update the main task + this.taskRegistry.updateTask({ + ...task, + state: 'error', + error: `Something went wrong while trying to create an inference server ${String(err)}.`, + }); + }); + return trackingId; + } + + /** + * Given an engineId, it will create an inference server. + * @param config + * @param trackingId + * + * @return the containerId of the created inference server + */ + async createInferenceServer(config: InferenceServerConfig, trackingId: string): Promise { + if (!this.isInitialize()) throw new Error('Cannot start the inference server: not initialized.'); + + // Fetch a provider container connection + const provider = getProviderContainerConnection(config.providerId); + + // Creating a task to follow pulling progress + const pullingTask = this.taskRegistry.createTask(`Pulling ${config.image}.`, 'loading', { trackingId: trackingId }); + + // Get the image inspect info + const imageInfo: ImageInfo = await getImageInfo(provider.connection, config.image, (_event: PullEvent) => {}); + + this.taskRegistry.updateTask({ + ...pullingTask, + state: 'success', + progress: undefined, + }); + + // upload models to podman machine if user system is supported + config.modelsInfo = await Promise.all( + config.modelsInfo.map(modelInfo => + this.modelsManager + .uploadModelToPodmanMachine(modelInfo, { + trackingId: trackingId, + }) + .then(path => ({ + ...modelInfo, + file: { + path: dirname(path), + file: basename(path), + }, + })), + ), + ); + + const containerTask = this.taskRegistry.createTask(`Creating container.`, 'loading', { trackingId: trackingId }); + + // Create container on requested engine + const result = await containerEngine.createContainer( + imageInfo.engineId, + generateContainerCreateOptions(config, imageInfo), + ); + + this.taskRegistry.updateTask({ + ...containerTask, + state: 'success', + }); + + // Adding a new inference server + this.#servers.set(result.id, { + container: { + engineId: imageInfo.engineId, + containerId: result.id, + }, + connection: { + port: config.port, + }, + status: 'running', + models: config.modelsInfo, + }); + + // Watch for container changes + this.watchContainerStatus(imageInfo.engineId, result.id); + + // Log usage + this.telemetry.logUsage('inference.start', { + models: config.modelsInfo.map(model => model.id), + }); + + this.notify(); + return result.id; + } + + /** + * Given an engineId and a containerId, inspect the container and update the servers + * @param engineId + * @param containerId + * @private + */ + private updateServerStatus(engineId: string, containerId: string): void { + // Inspect container + containerEngine + .inspectContainer(engineId, containerId) + .then(result => { + const server = this.#servers.get(containerId); + if (server === undefined) + throw new Error('Something went wrong while trying to get container status got undefined Inference Server.'); + + // Update server + this.#servers.set(containerId, { + ...server, + status: result.State.Status === 'running' ? 'running' : 'stopped', + health: result.State.Health, + }); + this.notify(); + }) + .catch((err: unknown) => { + console.error( + `Something went wrong while trying to inspect container ${containerId}. Trying to refresh servers.`, + err, + ); + this.retryableRefresh(2); + }); + } + + /** + * Watch for container status changes + * @param engineId + * @param containerId the container to watch out + */ + private watchContainerStatus(engineId: string, containerId: string): void { + // Update now + this.updateServerStatus(engineId, containerId); + + // Create a pulling update for container health check + const intervalId = setInterval(this.updateServerStatus.bind(this, engineId, containerId), 10000); + + this.#disposables.push( + Disposable.create(() => { + clearInterval(intervalId); + }), + ); + // Subscribe to container status update + const disposable = this.containerRegistry.subscribe(containerId, (status: string) => { + switch (status) { + case 'remove': + // Update the list of servers + this.removeInferenceServer(containerId); + disposable.dispose(); + clearInterval(intervalId); + break; + } + }); + // Allowing cleanup if extension is stopped + this.#disposables.push(disposable); + } + + private watchMachineEvent(_event: 'start' | 'stop'): void { + this.retryableRefresh(2); + } + + /** + * Listener for container start events + * @param event the event containing the id of the container + */ + private watchContainerStart(event: ContainerStart): void { + // We might have a start event for an inference server we already know about + if (this.#servers.has(event.id)) return; + + containerEngine + .listContainers() + .then(containers => { + const container = containers.find(c => c.Id === event.id); + if (container === undefined) { + return; + } + if (container.Labels && LABEL_INFERENCE_SERVER in container.Labels) { + this.watchContainerStatus(container.engineId, container.Id); + } + }) + .catch((err: unknown) => { + console.error(`Something went wrong in container start listener.`, err); + }); + } + + /** + * This non-async utility method is made to retry refreshing the inference server with some delay + * in case of error raised. + * + * @param retry the number of retry allowed + */ + private retryableRefresh(retry: number = 3): void { + if (retry === 0) { + console.error('Cannot refresh inference servers: retry limit has been reached. Cleaning manager.'); + this.cleanDisposables(); + this.#servers.clear(); + this.#initialized = false; + return; + } + this.refreshInferenceServers().catch((err: unknown): void => { + console.warn(`Something went wrong while trying to refresh inference server. (retry left ${retry})`, err); + setTimeout( + () => { + this.retryableRefresh(retry - 1); + }, + 2000 + Math.random() * 1000, + ); + }); + } + + /** + * Refresh the inference servers by listing all containers. + * + * This method has an important impact as it (re-)create all inference servers + */ + private async refreshInferenceServers(): Promise { + const containers: ContainerInfo[] = await containerEngine.listContainers(); + const filtered = containers.filter(c => c.Labels && LABEL_INFERENCE_SERVER in c.Labels); + + // clean existing disposables + this.cleanDisposables(); + this.#servers = new Map( + filtered.map(containerInfo => { + let modelsId: string[] = []; + try { + modelsId = JSON.parse(containerInfo.Labels[LABEL_INFERENCE_SERVER]); + } catch (err: unknown) { + console.error('Something went wrong while getting the models ids from the label.', err); + } + + return [ + containerInfo.Id, + { + container: { + containerId: containerInfo.Id, + engineId: containerInfo.engineId, + }, + connection: { + port: !!containerInfo.Ports && containerInfo.Ports.length > 0 ? containerInfo.Ports[0].PublicPort : -1, + }, + status: containerInfo.Status === 'running' ? 'running' : 'stopped', + models: modelsId.map(id => this.modelsManager.getModelInfo(id)), + }, + ]; + }), + ); + + // (re-)create container watchers + this.#servers.forEach(server => this.watchContainerStatus(server.container.engineId, server.container.containerId)); + this.#initialized = true; + // notify update + this.notify(); + } + + /** + * Remove the reference of the inference server + * /!\ Does not delete the corresponding container + * @param containerId + */ + private removeInferenceServer(containerId: string): void { + this.#servers.delete(containerId); + this.notify(); + } + + /** + * Delete the InferenceServer instance from #servers and matching container + * @param containerId the id of the container running the Inference Server + */ + async deleteInferenceServer(containerId: string): Promise { + if (!this.#servers.has(containerId)) { + throw new Error(`cannot find a corresponding server for container id ${containerId}.`); + } + const server = this.#servers.get(containerId); + + try { + // If the server is running we need to stop it. + if (server.status === 'running') { + await containerEngine.stopContainer(server.container.engineId, server.container.containerId); + } + // Delete the container + await containerEngine.deleteContainer(server.container.engineId, server.container.containerId); + + // Delete the reference + this.removeInferenceServer(containerId); + } catch (err: unknown) { + console.error('Something went wrong while trying to delete the inference server.', err); + this.retryableRefresh(2); + } + } + + /** + * Start an inference server from the container id + * @param containerId the identifier of the container to start + */ + async startInferenceServer(containerId: string): Promise { + if (!this.isInitialize()) throw new Error('Cannot start the inference server.'); + + const server = this.#servers.get(containerId); + if (server === undefined) throw new Error(`cannot find a corresponding server for container id ${containerId}.`); + + try { + await containerEngine.startContainer(server.container.engineId, server.container.containerId); + this.#servers.set(server.container.containerId, { + ...server, + status: 'running', + health: undefined, // remove existing health checks + }); + this.notify(); + } catch (error: unknown) { + console.error(error); + this.telemetry.logError('inference.start', { + message: 'error starting inference', + error: error, + }); + } + } + + /** + * Stop an inference server from the container id + * @param containerId the identifier of the container to stop + */ + async stopInferenceServer(containerId?: string): Promise { + if (!this.isInitialize()) throw new Error('Cannot stop the inference server.'); + + const server = this.#servers.get(containerId); + if (server === undefined) throw new Error(`cannot find a corresponding server for container id ${containerId}.`); + + try { + await containerEngine.stopContainer(server.container.engineId, server.container.containerId); + this.#servers.set(server.container.containerId, { + ...server, + status: 'stopped', + health: undefined, // remove existing health checks + }); + this.notify(); + } catch (error: unknown) { + console.error(error); + this.telemetry.logError('inference.stop', { + message: 'error stopping inference', + error: error, + }); + } + } +} diff --git a/packages/backend/src/managers/modelsManager.spec.ts b/packages/backend/src/managers/modelsManager.spec.ts index 47f1faf55..d7775bf80 100644 --- a/packages/backend/src/managers/modelsManager.spec.ts +++ b/packages/backend/src/managers/modelsManager.spec.ts @@ -1,14 +1,84 @@ -import { type MockInstance, beforeEach, expect, test, vi } from 'vitest'; +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { type MockInstance, beforeEach, describe, expect, test, vi } from 'vitest'; import os from 'os'; -import fs from 'node:fs'; +import fs, { type Stats, type PathLike } from 'node:fs'; import path from 'node:path'; import { ModelsManager } from './modelsManager'; -import type { Webview } from '@podman-desktop/api'; +import type { TelemetryLogger, Webview } from '@podman-desktop/api'; import type { CatalogManager } from './catalogManager'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import * as utils from '../utils/utils'; +import { TaskRegistry } from '../registries/TaskRegistry'; + +const mocks = vi.hoisted(() => { + return { + showErrorMessageMock: vi.fn(), + logUsageMock: vi.fn(), + logErrorMock: vi.fn(), + performDownloadMock: vi.fn(), + onEventDownloadMock: vi.fn(), + getTargetMock: vi.fn(), + getDownloaderCompleter: vi.fn(), + isCompletionEventMock: vi.fn(), + }; +}); + +vi.mock('@podman-desktop/api', () => { + return { + fs: { + createFileSystemWatcher: () => ({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + onDidChange: vi.fn(), + }), + }, + window: { + showErrorMessage: mocks.showErrorMessageMock, + }, + }; +}); + +vi.mock('../utils/downloader', () => ({ + isCompletionEvent: mocks.isCompletionEventMock, + Downloader: class { + get completed() { + return mocks.getDownloaderCompleter(); + } + onEvent = mocks.onEventDownloadMock; + perform = mocks.performDownloadMock; + getTarget = mocks.getTargetMock; + }, +})); + +let taskRegistry: TaskRegistry; + +const telemetryLogger = { + logUsage: mocks.logUsageMock, + logError: mocks.logErrorMock, +} as unknown as TelemetryLogger; beforeEach(() => { vi.resetAllMocks(); + taskRegistry = new TaskRegistry({ postMessage: vi.fn().mockResolvedValue(undefined) } as unknown as Webview); + + mocks.isCompletionEventMock.mockReturnValue(true); }); const dirent = [ @@ -34,7 +104,7 @@ function mockFiles(now: Date) { const existsSyncSpy = vi.spyOn(fs, 'existsSync'); existsSyncSpy.mockImplementation((path: string) => { if (process.platform === 'win32') { - expect(path).toBe('\\home\\user\\aistudio\\models'); + expect(path).toBe('C:\\home\\user\\aistudio\\models'); } else { expect(path).toBe('/home/user/aistudio/models'); } @@ -59,61 +129,192 @@ function mockFiles(now: Date) { }); } -test('getLocalModelsFromDisk should get models in local directory', () => { +test('getModelsInfo should get models in local directory', async () => { const now = new Date(); mockFiles(now); - const manager = new ModelsManager('/home/user/aistudio', {} as Webview, {} as CatalogManager); - manager.getLocalModelsFromDisk(); - expect(manager.getLocalModels()).toEqual([ + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const manager = new ModelsManager( + appdir, + { + postMessage: vi.fn(), + } as unknown as Webview, + { + getModels(): ModelInfo[] { + return [ + { id: 'model-id-1', name: 'model-id-1-model' } as ModelInfo, + { id: 'model-id-2', name: 'model-id-2-model' } as ModelInfo, + ]; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + await manager.loadLocalModels(); + expect(manager.getModelsInfo()).toEqual([ { id: 'model-id-1', - file: 'model-id-1-model', - size: 32000, - creation: now, - path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'), + name: 'model-id-1-model', + file: { + size: 32000, + creation: now, + path: path.resolve(dirent[0].path, dirent[0].name), + file: 'model-id-1-model', + }, }, { id: 'model-id-2', - file: 'model-id-2-model', - size: 32000, - creation: now, - path: path.resolve(dirent[1].path, dirent[1].name, 'model-id-2-model'), + name: 'model-id-2-model', + file: { + size: 32000, + creation: now, + path: path.resolve(dirent[1].path, dirent[1].name), + file: 'model-id-2-model', + }, }, ]); }); -test('getLocalModelsFromDisk should return an empty array if the models folder does not exist', () => { +test('getModelsInfo should return an empty array if the models folder does not exist', () => { vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); const existsSyncSpy = vi.spyOn(fs, 'existsSync'); existsSyncSpy.mockReturnValue(false); - const manager = new ModelsManager('/home/user/aistudio', {} as Webview, {} as CatalogManager); + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const manager = new ModelsManager( + appdir, + {} as Webview, + { + getModels(): ModelInfo[] { + return []; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); manager.getLocalModelsFromDisk(); - expect(manager.getLocalModels()).toEqual([]); + expect(manager.getModelsInfo()).toEqual([]); if (process.platform === 'win32') { - expect(existsSyncSpy).toHaveBeenCalledWith('\\home\\user\\aistudio\\models'); + expect(existsSyncSpy).toHaveBeenCalledWith('C:\\home\\user\\aistudio\\models'); } else { expect(existsSyncSpy).toHaveBeenCalledWith('/home/user/aistudio/models'); } }); -test('loadLocalModels should post a message with the message on disk and on catalog', async () => { +test('getLocalModelsFromDisk should return undefined Date and size when stat fail', async () => { const now = new Date(); mockFiles(now); + const statSyncSpy = vi.spyOn(fs, 'statSync'); + statSyncSpy.mockImplementation((path: PathLike) => { + if (`${path}`.endsWith('model-id-1')) throw new Error('random-error'); + return { isDirectory: () => true } as Stats; + }); - vi.mock('@podman-desktop/api', () => { - return { - fs: { - createFileSystemWatcher: () => ({ - onDidCreate: vi.fn(), - onDidDelete: vi.fn(), - onDidChange: vi.fn(), - }), + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const manager = new ModelsManager( + appdir, + { + postMessage: vi.fn(), + } as unknown as Webview, + { + getModels(): ModelInfo[] { + return [{ id: 'model-id-1', name: 'model-id-1-model' } as ModelInfo]; }, - }; + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + await manager.loadLocalModels(); + expect(manager.getModelsInfo()).toEqual([ + { + id: 'model-id-1', + name: 'model-id-1-model', + file: { + size: undefined, + creation: undefined, + path: path.resolve(dirent[0].path, dirent[0].name), + file: 'model-id-1-model', + }, + }, + ]); +}); + +test('getLocalModelsFromDisk should skip folders containing tmp files', async () => { + const now = new Date(); + mockFiles(now); + const statSyncSpy = vi.spyOn(fs, 'statSync'); + statSyncSpy.mockImplementation((path: PathLike) => { + if (`${path}`.endsWith('model-id-1')) throw new Error('random-error'); + return { isDirectory: () => true } as Stats; + }); + + const readdirSyncMock = vi.spyOn(fs, 'readdirSync') as unknown as MockInstance< + [path: string], + string[] | fs.Dirent[] + >; + readdirSyncMock.mockImplementation((dir: string) => { + if (dir.endsWith('model-id-1') || dir.endsWith('model-id-2')) { + const base = path.basename(dir); + return [base + '-model.tmp']; + } else { + return dirent; + } }); + + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const manager = new ModelsManager( + appdir, + { + postMessage: vi.fn(), + } as unknown as Webview, + { + getModels(): ModelInfo[] { + return [{ id: 'model-id-1', name: 'model-id-1-model' } as ModelInfo]; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + await manager.loadLocalModels(); + expect(manager.getModelsInfo()).toEqual([ + { + id: 'model-id-1', + name: 'model-id-1-model', + }, + ]); +}); + +test('loadLocalModels should post a message with the message on disk and on catalog', async () => { + const now = new Date(); + mockFiles(now); + const postMessageMock = vi.fn(); + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } const manager = new ModelsManager( - '/home/user/aistudio', + appdir, { postMessage: postMessageMock, } as unknown as Webview, @@ -126,21 +327,269 @@ test('loadLocalModels should post a message with the message on disk and on cata ] as ModelInfo[]; }, } as CatalogManager, + telemetryLogger, + taskRegistry, ); await manager.loadLocalModels(); expect(postMessageMock).toHaveBeenNthCalledWith(1, { - id: 'new-local-models-state', + id: 'new-models-state', body: [ { file: { creation: now, file: 'model-id-1-model', - id: 'model-id-1', size: 32000, - path: path.resolve(dirent[0].path, dirent[0].name, 'model-id-1-model'), + path: path.resolve(dirent[0].path, dirent[0].name), }, id: 'model-id-1', }, ], }); }); + +test('deleteLocalModel deletes the model folder', async () => { + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const now = new Date(); + mockFiles(now); + const rmSpy = vi.spyOn(fs.promises, 'rm'); + rmSpy.mockResolvedValue(); + const postMessageMock = vi.fn(); + const manager = new ModelsManager( + appdir, + { + postMessage: postMessageMock, + } as unknown as Webview, + { + getModels: () => { + return [ + { + id: 'model-id-1', + }, + ] as ModelInfo[]; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + await manager.loadLocalModels(); + await manager.deleteLocalModel('model-id-1'); + // check that the model's folder is removed from disk + if (process.platform === 'win32') { + expect(rmSpy).toBeCalledWith('C:\\home\\user\\aistudio\\models\\model-id-1', { recursive: true }); + } else { + expect(rmSpy).toBeCalledWith('/home/user/aistudio/models/model-id-1', { recursive: true }); + } + expect(postMessageMock).toHaveBeenCalledTimes(3); + // check that a new state is sent with the model removed + expect(postMessageMock).toHaveBeenNthCalledWith(3, { + id: 'new-models-state', + body: [ + { + id: 'model-id-1', + }, + ], + }); + expect(mocks.logUsageMock).toHaveBeenNthCalledWith(1, 'model.delete', { 'model.id': 'model-id-1' }); +}); + +test('deleteLocalModel fails to delete the model folder', async () => { + let appdir: string; + if (process.platform === 'win32') { + appdir = 'C:\\home\\user\\aistudio'; + } else { + appdir = '/home/user/aistudio'; + } + const now = new Date(); + mockFiles(now); + const rmSpy = vi.spyOn(fs.promises, 'rm'); + rmSpy.mockRejectedValue(new Error('failed')); + const postMessageMock = vi.fn(); + const manager = new ModelsManager( + appdir, + { + postMessage: postMessageMock, + } as unknown as Webview, + { + getModels: () => { + return [ + { + id: 'model-id-1', + }, + ] as ModelInfo[]; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + await manager.loadLocalModels(); + await manager.deleteLocalModel('model-id-1'); + // check that the model's folder is removed from disk + if (process.platform === 'win32') { + expect(rmSpy).toBeCalledWith('C:\\home\\user\\aistudio\\models\\model-id-1', { recursive: true }); + } else { + expect(rmSpy).toBeCalledWith('/home/user/aistudio/models/model-id-1', { recursive: true }); + } + expect(postMessageMock).toHaveBeenCalledTimes(3); + // check that a new state is sent with the model non removed + expect(postMessageMock).toHaveBeenNthCalledWith(3, { + id: 'new-models-state', + body: [ + { + id: 'model-id-1', + file: { + creation: now, + file: 'model-id-1-model', + size: 32000, + path: path.resolve(dirent[0].path, dirent[0].name), + }, + }, + ], + }); + expect(mocks.showErrorMessageMock).toHaveBeenCalledOnce(); + expect(mocks.logErrorMock).toHaveBeenCalled(); +}); + +describe('downloadModel', () => { + test('download model if not already on disk', async () => { + const manager = new ModelsManager( + 'appdir', + {} as Webview, + { + getModels(): ModelInfo[] { + return []; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + + vi.spyOn(manager, 'isModelOnDisk').mockReturnValue(false); + vi.spyOn(utils, 'getDurationSecondsSince').mockReturnValue(99); + const updateTaskMock = vi.spyOn(taskRegistry, 'updateTask'); + await manager.requestDownloadModel({ + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo); + expect(updateTaskMock).toHaveBeenLastCalledWith({ + id: expect.any(String), + name: 'Downloading model name', + labels: { + 'model-pulling': 'id', + }, + state: 'loading', + }); + }); + test('retrieve model path if already on disk', async () => { + const manager = new ModelsManager( + 'appdir', + {} as Webview, + { + getModels(): ModelInfo[] { + return []; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + const updateTaskMock = vi.spyOn(taskRegistry, 'updateTask'); + vi.spyOn(manager, 'isModelOnDisk').mockReturnValue(true); + const getLocalModelPathMock = vi.spyOn(manager, 'getLocalModelPath').mockReturnValue(''); + await manager.requestDownloadModel({ + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo); + expect(getLocalModelPathMock).toBeCalledWith('id'); + expect(updateTaskMock).toHaveBeenLastCalledWith({ + id: expect.any(String), + name: 'Model name already present on disk', + labels: { + 'model-pulling': 'id', + }, + state: 'success', + }); + }); + test('multiple download request same model - second call after first completed', async () => { + mocks.getDownloaderCompleter.mockReturnValue(true); + + const manager = new ModelsManager( + 'appdir', + {} as Webview, + { + getModels(): ModelInfo[] { + return []; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + + vi.spyOn(manager, 'isModelOnDisk').mockReturnValue(false); + vi.spyOn(utils, 'getDurationSecondsSince').mockReturnValue(99); + + await manager.requestDownloadModel({ + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo); + + await manager.requestDownloadModel({ + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo); + + // Only called once + expect(mocks.performDownloadMock).toHaveBeenCalledTimes(1); + expect(mocks.onEventDownloadMock).toHaveBeenCalledTimes(1); + }); + + test('multiple download request same model - second call before first completed', async () => { + mocks.getDownloaderCompleter.mockReturnValue(false); + + const manager = new ModelsManager( + 'appdir', + {} as Webview, + { + getModels(): ModelInfo[] { + return []; + }, + } as CatalogManager, + telemetryLogger, + taskRegistry, + ); + + vi.spyOn(manager, 'isModelOnDisk').mockReturnValue(false); + vi.spyOn(utils, 'getDurationSecondsSince').mockReturnValue(99); + + mocks.onEventDownloadMock.mockImplementation(listener => { + listener({ + id: 'id', + status: 'completed', + duration: 1000, + }); + }); + + await manager.requestDownloadModel({ + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo); + + await manager.requestDownloadModel({ + id: 'id', + url: 'url', + name: 'name', + } as ModelInfo); + + // Only called once + expect(mocks.performDownloadMock).toHaveBeenCalledTimes(1); + expect(mocks.onEventDownloadMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/backend/src/managers/modelsManager.ts b/packages/backend/src/managers/modelsManager.ts index b2a266222..193da7ae9 100644 --- a/packages/backend/src/managers/modelsManager.ts +++ b/packages/backend/src/managers/modelsManager.ts @@ -1,52 +1,97 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import type { LocalModelInfo } from '@shared/src/models/ILocalModelInfo'; import fs from 'fs'; import * as path from 'node:path'; -import { type Webview, fs as apiFs } from '@podman-desktop/api'; -import { MSG_NEW_LOCAL_MODELS_STATE } from '@shared/Messages'; +import { type Webview, fs as apiFs, type Disposable } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; import type { CatalogManager } from './catalogManager'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import { Downloader } from '../utils/downloader'; +import type { TaskRegistry } from '../registries/TaskRegistry'; +import type { Task } from '@shared/src/models/ITask'; +import type { BaseEvent } from '../models/baseEvent'; +import { isCompletionEvent, isProgressEvent } from '../models/baseEvent'; +import { Uploader } from '../utils/uploader'; +import { getLocalModelFile } from '../utils/modelsUtils'; -export class ModelsManager { +export class ModelsManager implements Disposable { #modelsDir: string; - #localModels: Map; + #models: Map; + #watcher?: podmanDesktopApi.FileSystemWatcher; + + #downloaders: Map = new Map(); constructor( private appUserDirectory: string, private webview: Webview, private catalogManager: CatalogManager, + private telemetry: podmanDesktopApi.TelemetryLogger, + private taskRegistry: TaskRegistry, ) { this.#modelsDir = path.join(this.appUserDirectory, 'models'); - this.#localModels = new Map(); + this.#models = new Map(); + } + + dispose(): void { + this.#models.clear(); + this.#watcher.dispose(); } async loadLocalModels() { + this.catalogManager.getModels().forEach(m => this.#models.set(m.id, m)); const reloadLocalModels = async () => { this.getLocalModelsFromDisk(); - const models = this.getModelsInfo(); - await this.webview.postMessage({ - id: MSG_NEW_LOCAL_MODELS_STATE, - body: models, - }); + await this.sendModelsInfo(); }; - const watcher = apiFs.createFileSystemWatcher(this.#modelsDir); - watcher.onDidCreate(reloadLocalModels); - watcher.onDidDelete(reloadLocalModels); - watcher.onDidChange(reloadLocalModels); + if (this.#watcher === undefined) { + this.#watcher = apiFs.createFileSystemWatcher(this.#modelsDir); + this.#watcher.onDidCreate(reloadLocalModels); + this.#watcher.onDidDelete(reloadLocalModels); + this.#watcher.onDidChange(reloadLocalModels); + } + // Initialize the local models manually await reloadLocalModels(); } getModelsInfo() { - return this.catalogManager - .getModels() - .filter(m => this.#localModels.has(m.id)) - .map(m => ({ ...m, file: this.#localModels.get(m.id) })); + return [...this.#models.values()]; + } + + async sendModelsInfo() { + const models = this.getModelsInfo(); + await this.webview.postMessage({ + id: Messages.MSG_NEW_MODELS_STATE, + body: models, + }); + } + + getModelsDirectory(): string { + return this.#modelsDir; } getLocalModelsFromDisk(): void { if (!fs.existsSync(this.#modelsDir)) { return; } - const result = new Map(); const entries = fs.readdirSync(this.#modelsDir, { withFileTypes: true }); const dirs = entries.filter(dir => dir.isDirectory()); for (const d of dirs) { @@ -57,35 +102,230 @@ export class ModelsManager { } const modelFile = modelEntries[0]; const fullPath = path.resolve(d.path, d.name, modelFile); - const info = fs.statSync(fullPath); - result.set(d.name, { - id: d.name, - file: modelFile, - path: fullPath, - size: info.size, - creation: info.mtime, - }); + + let info: { size?: number; mtime?: Date } = { size: undefined, mtime: undefined }; + try { + info = fs.statSync(fullPath); + } catch (err: unknown) { + console.error('Something went wrong while getting file stats (probably in use).', err); + } + + const model = this.#models.get(d.name); + if (model) { + // if the model file ends with .tmp and it is not in downloaders list, + // we skip it as it was not probably downloaded completed in a previous session + if (fullPath.endsWith('.tmp') && !this.#downloaders.has(model.id)) { + continue; + } + + model.file = { + file: modelFile, + path: path.resolve(d.path, d.name), + size: info.size, + creation: info.mtime, + }; + } } - this.#localModels = result; } isModelOnDisk(modelId: string) { - return this.#localModels.has(modelId); + return this.#models.get(modelId)?.file !== undefined; } getLocalModelInfo(modelId: string): LocalModelInfo { if (!this.isModelOnDisk(modelId)) { throw new Error('model is not on disk'); } - return this.#localModels.get(modelId); + return this.#models.get(modelId).file; + } + + getModelInfo(modelId: string): ModelInfo { + const model = this.#models.get(modelId); + if (!model) { + throw new Error('model is not loaded'); + } + return model; } getLocalModelPath(modelId: string): string { - const info = this.getLocalModelInfo(modelId); - return path.resolve(this.#modelsDir, modelId, info.file); + return getLocalModelFile(this.getModelInfo(modelId)); + } + + getLocalModelFolder(modelId: string): string { + return path.resolve(this.#modelsDir, modelId); + } + + async deleteLocalModel(modelId: string): Promise { + const model = this.#models.get(modelId); + if (model) { + const modelDir = this.getLocalModelFolder(modelId); + model.state = 'deleting'; + await this.sendModelsInfo(); + try { + await fs.promises.rm(modelDir, { recursive: true }); + this.telemetry.logUsage('model.delete', { 'model.id': modelId }); + model.file = model.state = undefined; + } catch (err: unknown) { + this.telemetry.logError('model.delete', { + 'model.id': modelId, + message: 'error deleting model from disk', + error: err, + }); + await podmanDesktopApi.window.showErrorMessage(`Error deleting model ${modelId}. ${String(err)}`); + + // Let's reload the models manually to avoid any issue + model.state = undefined; + this.getLocalModelsFromDisk(); + } finally { + await this.sendModelsInfo(); + } + } + } + + /** + * This method will resolve when the provided model will be downloaded. + * + * This can method can be call multiple time for the same model, it will reuse existing downloader and wait on + * their completion. + * @param model + * @param labels + */ + async requestDownloadModel(model: ModelInfo, labels?: { [key: string]: string }): Promise { + // Create a task to follow progress + const task: Task = this.createDownloadTask(model, labels); + + // Check there is no existing downloader running + if (!this.#downloaders.has(model.id)) { + return this.downloadModel(model, task); + } + + const existingDownloader = this.#downloaders.get(model.id); + if (existingDownloader.completed) { + task.state = 'success'; + this.taskRegistry.updateTask(task); + + return existingDownloader.getTarget(); + } + + // If we have an existing downloader running we subscribe on its events + return new Promise((resolve, reject) => { + const disposable = existingDownloader.onEvent(event => { + if (!isCompletionEvent(event)) return; + + switch (event.status) { + case 'completed': + resolve(existingDownloader.getTarget()); + break; + default: + reject(new Error(event.message)); + } + disposable.dispose(); + }); + }); + } + + private onDownloadUploadEvent(event: BaseEvent, action: 'download' | 'upload'): void { + let taskLabel = 'model-pulling'; + let eventName = 'model.download'; + if (action === 'upload') { + taskLabel = 'model-uploading'; + eventName = 'model.upload'; + } + // Always use the task registry as source of truth for tasks + const tasks = this.taskRegistry.getTasksByLabels({ [taskLabel]: event.id }); + if (tasks.length === 0) { + // tasks might have been cleared but still an error. + console.error(`received ${action} event but no task is associated.`); + return; + } + + tasks.forEach(task => { + if (isProgressEvent(event)) { + task.state = 'loading'; + task.progress = event.value; + } else if (isCompletionEvent(event)) { + // status error or canceled + if (event.status === 'error' || event.status === 'canceled') { + task.state = 'error'; + task.progress = undefined; + task.error = event.message; + + // telemetry usage + this.telemetry.logError(eventName, { + 'model.id': event.id, + message: `error ${action}ing model`, + error: event.message, + durationSeconds: event.duration, + }); + } else { + task.state = 'success'; + task.progress = 100; + + // telemetry usage + this.telemetry.logUsage(eventName, { 'model.id': event.id, durationSeconds: event.duration }); + } + } + this.taskRegistry.updateTask(task); // update task + }); + } + + private createDownloader(model: ModelInfo): Downloader { + // Ensure path to model directory exist + const destDir = path.join(this.appUserDirectory, 'models', model.id); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + const target = path.resolve(destDir, path.basename(model.url)); + // Create a downloader + const downloader = new Downloader(model.url, target); + + this.#downloaders.set(model.id, downloader); + + return downloader; + } + + private createDownloadTask(model: ModelInfo, labels?: { [key: string]: string }): Task { + return this.taskRegistry.createTask(`Downloading model ${model.name}`, 'loading', { + ...labels, + 'model-pulling': model.id, + }); + } + + private async downloadModel(model: ModelInfo, task: Task): Promise { + // Check if the model is already on disk. + if (this.isModelOnDisk(model.id)) { + task.state = 'success'; + task.name = `Model ${model.name} already present on disk`; + this.taskRegistry.updateTask(task); // update task + + // return model path + return this.getLocalModelPath(model.id); + } + + // update task to loading state + this.taskRegistry.updateTask(task); + + const downloader = this.createDownloader(model); + + // Capture downloader events + downloader.onEvent(event => this.onDownloadUploadEvent(event, 'download'), this); + + // perform download + await downloader.perform(model.id); + return downloader.getTarget(); } - getLocalModels(): LocalModelInfo[] { - return Array.from(this.#localModels.values()); + async uploadModelToPodmanMachine(model: ModelInfo, labels?: { [key: string]: string }): Promise { + this.taskRegistry.createTask(`Uploading model ${model.name}`, 'loading', { + ...labels, + 'model-uploading': model.id, + }); + + const uploader = new Uploader(model); + uploader.onEvent(event => this.onDownloadUploadEvent(event, 'upload'), this); + + // perform download + return uploader.perform(model.id); } } diff --git a/packages/backend/src/managers/monitoringManager.spec.ts b/packages/backend/src/managers/monitoringManager.spec.ts new file mode 100644 index 000000000..1858a435c --- /dev/null +++ b/packages/backend/src/managers/monitoringManager.spec.ts @@ -0,0 +1,201 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, expect, afterEach, test, vi } from 'vitest'; +import { MonitoringManager } from './monitoringManager'; +import { containerEngine, type ContainerStatsInfo, type Webview } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; + +vi.mock('@podman-desktop/api', async () => { + return { + containerEngine: { + statsContainer: vi.fn(), + }, + }; +}); + +const webviewMock = { + postMessage: vi.fn(), +} as unknown as Webview; + +beforeEach(() => { + vi.resetAllMocks(); + + vi.mocked(webviewMock.postMessage).mockResolvedValue(undefined); + vi.mocked(containerEngine.statsContainer).mockResolvedValue(undefined); + + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function simplifiedCallback(callback: (arg: ContainerStatsInfo) => void, cpu: number, ram: number): void { + callback({ + cpu_stats: { + cpu_usage: { + total_usage: cpu, + }, + }, + memory_stats: { + usage: ram, + }, + } as unknown as ContainerStatsInfo); +} + +test('expect constructor to do nothing', () => { + const manager = new MonitoringManager(webviewMock); + expect(containerEngine.statsContainer).not.toHaveBeenCalled(); + expect(manager.getStats().length).toBe(0); + expect(webviewMock.postMessage).not.toHaveBeenCalled(); +}); + +test('expect monitor method to start stats container', async () => { + const manager = new MonitoringManager(webviewMock); + await manager.monitor('randomContainerId', 'dummyEngineId'); + + expect(containerEngine.statsContainer).toHaveBeenCalledWith('dummyEngineId', 'randomContainerId', expect.anything()); +}); + +test('expect monitor method to start stats container', async () => { + const manager = new MonitoringManager(webviewMock); + await manager.monitor('randomContainerId', 'dummyEngineId'); + + expect(containerEngine.statsContainer).toHaveBeenCalledWith('dummyEngineId', 'randomContainerId', expect.anything()); +}); + +test('expect dispose to dispose stats container', async () => { + const manager = new MonitoringManager(webviewMock); + const fakeDisposable = vi.fn(); + vi.mocked(containerEngine.statsContainer).mockResolvedValue({ + dispose: fakeDisposable, + }); + + await manager.monitor('randomContainerId', 'dummyEngineId'); + + manager.dispose(); + expect(fakeDisposable).toHaveBeenCalled(); +}); + +test('expect webview to be notified when statsContainer call back', async () => { + const manager = new MonitoringManager(webviewMock); + let mCallback: (stats: ContainerStatsInfo) => void; + vi.mocked(containerEngine.statsContainer).mockImplementation(async (_engineId, _id, callback) => { + mCallback = callback; + return { dispose: () => {} }; + }); + + await manager.monitor('randomContainerId', 'dummyEngineId'); + await vi.waitFor(() => { + expect(mCallback).toBeDefined(); + }); + + const date = new Date(2000, 1, 1, 13); + vi.setSystemTime(date); + + simplifiedCallback(mCallback, 123, 99); + + expect(webviewMock.postMessage).toHaveBeenCalledWith({ + id: Messages.MSG_MONITORING_UPDATE, + body: [ + { + containerId: 'randomContainerId', + stats: [ + { + timestamp: Date.now(), + cpu_usage: 123, + memory_usage: 99, + }, + ], + }, + ], + }); +}); + +test('expect stats to cumulate', async () => { + const manager = new MonitoringManager(webviewMock); + let mCallback: (stats: ContainerStatsInfo) => void; + vi.mocked(containerEngine.statsContainer).mockImplementation(async (_engineId, _id, callback) => { + mCallback = callback; + return { dispose: () => {} }; + }); + + await manager.monitor('randomContainerId', 'dummyEngineId'); + await vi.waitFor(() => { + expect(mCallback).toBeDefined(); + }); + + simplifiedCallback(mCallback, 0, 0); + simplifiedCallback(mCallback, 1, 1); + simplifiedCallback(mCallback, 2, 2); + simplifiedCallback(mCallback, 3, 3); + + const stats = manager.getStats(); + expect(stats.length).toBe(1); + expect(stats[0].stats.length).toBe(4); +}); + +test('expect old stats to be removed', async () => { + const manager = new MonitoringManager(webviewMock); + let mCallback: (stats: ContainerStatsInfo) => void; + vi.mocked(containerEngine.statsContainer).mockImplementation(async (_engineId, _id, callback) => { + mCallback = callback; + return { dispose: () => {} }; + }); + + await manager.monitor('randomContainerId', 'dummyEngineId'); + await vi.waitFor(() => { + expect(mCallback).toBeDefined(); + }); + + vi.setSystemTime(new Date(2000, 1, 1, 13)); + + simplifiedCallback(mCallback, 0, 0); + + vi.setSystemTime(new Date(2005, 1, 1, 13)); + + simplifiedCallback(mCallback, 1, 1); + simplifiedCallback(mCallback, 2, 2); + simplifiedCallback(mCallback, 3, 3); + + const stats = manager.getStats(); + expect(stats.length).toBe(1); + expect(stats[0].stats.length).toBe(3); +}); + +test('expect stats to be disposed if stats result is an error', async () => { + const manager = new MonitoringManager(webviewMock); + let mCallback: (stats: ContainerStatsInfo) => void; + const fakeDisposable = vi.fn(); + vi.mocked(containerEngine.statsContainer).mockImplementation(async (_engineId, _id, callback) => { + mCallback = callback; + return { dispose: fakeDisposable }; + }); + + await manager.monitor('randomContainerId', 'dummyEngineId'); + await vi.waitFor(() => { + expect(mCallback).toBeDefined(); + }); + + mCallback({ cause: 'container is stopped' } as unknown as ContainerStatsInfo); + + const stats = manager.getStats(); + expect(stats.length).toBe(0); + expect(fakeDisposable).toHaveBeenCalled(); +}); diff --git a/packages/backend/src/managers/monitoringManager.ts b/packages/backend/src/managers/monitoringManager.ts new file mode 100644 index 000000000..cbc626c35 --- /dev/null +++ b/packages/backend/src/managers/monitoringManager.ts @@ -0,0 +1,90 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { type Disposable, type Webview, containerEngine, type ContainerStatsInfo } from '@podman-desktop/api'; +import { Publisher } from '../utils/Publisher'; +import { Messages } from '@shared/Messages'; + +export interface StatsInfo { + timestamp: number; + cpu_usage: number; + memory_usage: number; +} + +export interface StatsHistory { + containerId: string; + stats: StatsInfo[]; +} + +export const MAX_AGE: number = 5 * 60 * 1000; // 5 minutes + +export class MonitoringManager extends Publisher implements Disposable { + #containerStats: Map; + #disposables: Disposable[]; + + constructor(webview: Webview) { + super(webview, Messages.MSG_MONITORING_UPDATE, () => this.getStats()); + this.#containerStats = new Map(); + this.#disposables = []; + } + + async monitor(containerId: string, engineId: string): Promise { + const disposable = await containerEngine.statsContainer(engineId, containerId, statsInfo => { + if ('cause' in statsInfo) { + console.error('Cannot stats container', statsInfo.cause); + disposable.dispose(); + } else { + this.push(containerId, statsInfo); + } + }); + this.#disposables.push(disposable); + return disposable; + } + + private push(containerId: string, statsInfo: ContainerStatsInfo): void { + let stats: StatsInfo[] = []; + if (this.#containerStats.has(containerId)) { + const limit = Date.now() - MAX_AGE; + stats = this.#containerStats.get(containerId).stats.filter(stats => stats.timestamp > limit); + } + + this.#containerStats.set(containerId, { + containerId: containerId, + stats: [ + ...stats, + { + timestamp: Date.now(), + cpu_usage: statsInfo.cpu_stats.cpu_usage.total_usage, + memory_usage: statsInfo.memory_stats.usage, + }, + ], + }); + this.notify(); + } + + clear(containerId: string): void { + this.#containerStats.delete(containerId); + } + + getStats(): StatsHistory[] { + return Array.from(this.#containerStats.values()); + } + + dispose(): void { + this.#disposables.forEach(disposable => disposable.dispose()); + } +} diff --git a/packages/backend/src/managers/playground.ts b/packages/backend/src/managers/playground.ts deleted file mode 100644 index 04820f4c8..000000000 --- a/packages/backend/src/managers/playground.ts +++ /dev/null @@ -1,248 +0,0 @@ -/********************************************************************** - * Copyright (C) 2024 Red Hat, Inc. - * - * 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. - * - * SPDX-License-Identifier: Apache-2.0 - ***********************************************************************/ - -import { - provider, - containerEngine, - type Webview, - type ProviderContainerConnection, - type ImageInfo, -} from '@podman-desktop/api'; -import type { LocalModelInfo } from '@shared/src/models/ILocalModelInfo'; -import type { ModelResponse } from '@shared/src/models/IModelResponse'; - -import path from 'node:path'; -import * as http from 'node:http'; -import { getFreePort } from '../utils/ports'; -import type { QueryState } from '@shared/src/models/IPlaygroundQueryState'; -import { MSG_NEW_PLAYGROUND_QUERIES_STATE, MSG_PLAYGROUNDS_STATE_UPDATE } from '@shared/Messages'; -import type { PlaygroundState, PlaygroundStatus } from '@shared/src/models/IPlaygroundState'; - -// TODO: this should not be hardcoded -const LOCALAI_IMAGE = 'quay.io/go-skynet/local-ai:v2.5.1'; - -function findFirstProvider(): ProviderContainerConnection | undefined { - const engines = provider - .getContainerConnections() - .filter(connection => connection.connection.type === 'podman') - .filter(connection => connection.connection.status() === 'started'); - return engines.length > 0 ? engines[0] : undefined; -} - -export class PlayGroundManager { - private queryIdCounter = 0; - - // Dict modelId => state - private playgrounds: Map; - private queries: Map; - - constructor(private webview: Webview) { - this.playgrounds = new Map(); - this.queries = new Map(); - } - - async selectImage(connection: ProviderContainerConnection, image: string): Promise { - const images = (await containerEngine.listImages()).filter(im => im.RepoTags?.some(tag => tag === image)); - return images.length > 0 ? images[0] : undefined; - } - - setPlaygroundStatus(modelId: string, status: PlaygroundStatus) { - return this.updatePlaygroundState(modelId, { - modelId: modelId, - ...(this.playgrounds.get(modelId) || {}), - status: status, - }); - } - - updatePlaygroundState(modelId: string, state: PlaygroundState) { - this.playgrounds.set(modelId, state); - return this.webview.postMessage({ - id: MSG_PLAYGROUNDS_STATE_UPDATE, - body: this.getPlaygroundsState(), - }); - } - - async startPlayground(modelId: string, modelPath: string): Promise { - // TODO(feloy) remove previous query from state? - if (this.playgrounds.has(modelId)) { - // TODO: check manually if the contains has a matching state - switch (this.playgrounds.get(modelId).status) { - case 'running': - throw new Error('playground is already running'); - case 'starting': - case 'stopping': - throw new Error('playground is transitioning'); - case 'error': - case 'none': - case 'stopped': - break; - } - } - - await this.setPlaygroundStatus(modelId, 'starting'); - - const connection = findFirstProvider(); - if (!connection) { - await this.setPlaygroundStatus(modelId, 'error'); - throw new Error('Unable to find an engine to start playground'); - } - - let image = await this.selectImage(connection, LOCALAI_IMAGE); - if (!image) { - await containerEngine.pullImage(connection.connection, LOCALAI_IMAGE, () => {}); - image = await this.selectImage(connection, LOCALAI_IMAGE); - if (!image) { - await this.setPlaygroundStatus(modelId, 'error'); - throw new Error(`Unable to find ${LOCALAI_IMAGE} image`); - } - } - - const freePort = await getFreePort(); - const result = await containerEngine.createContainer(image.engineId, { - Image: image.Id, - Detach: true, - ExposedPorts: { ['' + freePort]: {} }, - HostConfig: { - AutoRemove: true, - Mounts: [ - { - Target: '/models', - Source: path.dirname(modelPath), - Type: 'bind', - }, - ], - PortBindings: { - '8080/tcp': [ - { - HostPort: '' + freePort, - }, - ], - }, - }, - Labels: { - 'ia-studio-model': modelId, - }, - Cmd: ['--models-path', '/models', '--context-size', '700', '--threads', '4'], - }); - - await this.updatePlaygroundState(modelId, { - container: { - containerId: result.id, - port: freePort, - engineId: image.engineId, - }, - status: 'running', - modelId, - }); - - return result.id; - } - - async stopPlayground(modelId: string): Promise { - const state = this.playgrounds.get(modelId); - if (state?.container === undefined) { - throw new Error('model is not running'); - } - await this.setPlaygroundStatus(modelId, 'stopping'); - // We do not await since it can take a lot of time - containerEngine - .stopContainer(state.container.engineId, state.container.containerId) - .then(async () => { - await this.setPlaygroundStatus(modelId, 'stopped'); - }) - .catch(async (error: unknown) => { - console.error(error); - await this.setPlaygroundStatus(modelId, 'error'); - }); - } - - async askPlayground(modelInfo: LocalModelInfo, prompt: string): Promise { - const state = this.playgrounds.get(modelInfo.id); - if (state?.container === undefined) { - throw new Error('model is not running'); - } - - const query = { - id: this.getNextQueryId(), - modelId: modelInfo.id, - prompt: prompt, - } as QueryState; - - const post_data = JSON.stringify({ - model: modelInfo.file, - prompt: prompt, - temperature: 0.7, - }); - - const post_options: http.RequestOptions = { - host: 'localhost', - port: '' + state.container.port, - path: '/v1/completions', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }; - - const post_req = http.request(post_options, res => { - res.setEncoding('utf8'); - const chunks = []; - res.on('data', data => chunks.push(data)); - res.on('end', () => { - const resBody = chunks.join(); - if (res.headers['content-type'] === 'application/json') { - const result = JSON.parse(resBody); - const q = this.queries.get(query.id); - if (!q) { - throw new Error('query not found in state'); - } - q.response = result as ModelResponse; - this.queries.set(query.id, q); - this.sendQueriesState().catch((err: unknown) => { - console.error('playground: unable to send the response to the frontend', err); - }); - } - }); - }); - // post the data - post_req.write(post_data); - post_req.end(); - - this.queries.set(query.id, query); - await this.sendQueriesState(); - return query.id; - } - - getNextQueryId() { - return ++this.queryIdCounter; - } - getQueriesState(): QueryState[] { - return Array.from(this.queries.values()); - } - - getPlaygroundsState(): PlaygroundState[] { - return Array.from(this.playgrounds.values()); - } - - async sendQueriesState() { - await this.webview.postMessage({ - id: MSG_NEW_PLAYGROUND_QUERIES_STATE, - body: this.getQueriesState(), - }); - } -} diff --git a/packages/backend/src/managers/playgroundV2Manager.spec.ts b/packages/backend/src/managers/playgroundV2Manager.spec.ts new file mode 100644 index 000000000..8a15e00b9 --- /dev/null +++ b/packages/backend/src/managers/playgroundV2Manager.spec.ts @@ -0,0 +1,539 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi, beforeEach, afterEach } from 'vitest'; +import OpenAI from 'openai'; +import { PlaygroundV2Manager } from './playgroundV2Manager'; +import type { Webview } from '@podman-desktop/api'; +import type { InferenceServer } from '@shared/src/models/IInference'; +import type { InferenceManager } from './inference/inferenceManager'; +import { Messages } from '@shared/Messages'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { INFERENCE_SERVER_IMAGE } from '../utils/inferenceUtils'; +import type { TaskRegistry } from '../registries/TaskRegistry'; +import type { Task } from '@shared/src/models/ITask'; + +vi.mock('openai', () => ({ + default: vi.fn(), +})); + +const webviewMock = { + postMessage: vi.fn(), +} as unknown as Webview; + +const inferenceManagerMock = { + get: vi.fn(), + getServers: vi.fn(), + createInferenceServer: vi.fn(), + startInferenceServer: vi.fn(), +} as unknown as InferenceManager; + +const taskRegistryMock = { + createTask: vi.fn(), + getTasksByLabels: vi.fn(), + updateTask: vi.fn(), +} as unknown as TaskRegistry; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(webviewMock.postMessage).mockResolvedValue(undefined); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('manager should be properly initialized', () => { + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + expect(manager.getConversations().length).toBe(0); +}); + +test('submit should throw an error if the server is stopped', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + status: 'running', + models: [ + { + id: 'model1', + }, + ], + } as unknown as InferenceServer, + ]); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground('playground 1', { id: 'model1' } as ModelInfo, 'tracking-1'); + + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + status: 'stopped', + models: [ + { + id: 'model1', + }, + ], + } as unknown as InferenceServer, + ]); + + await expect(manager.submit('0', 'dummyUserInput', '')).rejects.toThrowError('Inference server is not running.'); +}); + +test('submit should throw an error if the server is unhealthy', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + status: 'running', + health: { + Status: 'unhealthy', + }, + models: [ + { + id: 'model1', + }, + ], + } as unknown as InferenceServer, + ]); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground('p1', { id: 'model1' } as ModelInfo, 'tracking-1'); + const playgroundId = manager.getPlaygrounds()[0].id; + await expect(manager.submit(playgroundId, 'dummyUserInput', '')).rejects.toThrowError( + 'Inference server is not healthy, currently status: unhealthy.', + ); +}); + +test('create playground should create conversation.', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + status: 'running', + health: { + Status: 'healthy', + }, + models: [ + { + id: 'dummyModelId', + file: { + file: 'dummyModelFile', + }, + }, + ], + } as unknown as InferenceServer, + ]); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + expect(manager.getConversations().length).toBe(0); + await manager.createPlayground('playground 1', { id: 'model-1' } as ModelInfo, 'tracking-1'); + + const conversations = manager.getConversations(); + expect(conversations.length).toBe(1); +}); + +test('valid submit should create IPlaygroundMessage and notify the webview', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + status: 'running', + health: { + Status: 'healthy', + }, + models: [ + { + id: 'dummyModelId', + file: { + file: 'dummyModelFile', + }, + }, + ], + connection: { + port: 8888, + }, + } as unknown as InferenceServer, + ]); + const createMock = vi.fn().mockResolvedValue([]); + vi.mocked(OpenAI).mockReturnValue({ + chat: { + completions: { + create: createMock, + }, + }, + } as unknown as OpenAI); + + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground('playground 1', { id: 'dummyModelId' } as ModelInfo, 'tracking-1'); + + const date = new Date(2000, 1, 1, 13); + vi.setSystemTime(date); + + const playgrounds = manager.getPlaygrounds(); + await manager.submit(playgrounds[0].id, 'dummyUserInput', ''); + + // Wait for assistant message to be completed + await vi.waitFor(() => { + expect(manager.getConversations()[0].messages[1].content).toBeDefined(); + }); + + const conversations = manager.getConversations(); + + expect(conversations.length).toBe(1); + expect(conversations[0].messages.length).toBe(2); + expect(conversations[0].messages[0]).toStrictEqual({ + content: 'dummyUserInput', + id: expect.anything(), + options: undefined, + role: 'user', + timestamp: expect.any(Number), + }); + expect(conversations[0].messages[1]).toStrictEqual({ + choices: undefined, + completed: expect.any(Number), + content: '', + id: expect.anything(), + role: 'assistant', + timestamp: expect.any(Number), + }); + + expect(webviewMock.postMessage).toHaveBeenLastCalledWith({ + id: Messages.MSG_CONVERSATIONS_UPDATE, + body: conversations, + }); +}); + +test.each(['', 'my system prompt'])( + 'valid submit should send a message with system prompt if non empty, system prompt is "%s"}', + async (systemPrompt: string) => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + status: 'running', + health: { + Status: 'healthy', + }, + models: [ + { + id: 'dummyModelId', + file: { + file: 'dummyModelFile', + }, + }, + ], + connection: { + port: 8888, + }, + } as unknown as InferenceServer, + ]); + const createMock = vi.fn().mockResolvedValue([]); + vi.mocked(OpenAI).mockReturnValue({ + chat: { + completions: { + create: createMock, + }, + }, + } as unknown as OpenAI); + + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground('playground 1', { id: 'dummyModelId' } as ModelInfo, 'tracking-1'); + + const playgrounds = manager.getPlaygrounds(); + await manager.submit(playgrounds[0].id, 'dummyUserInput', systemPrompt); + + const messages: unknown[] = [ + { + content: 'dummyUserInput', + id: expect.any(String), + role: 'user', + timestamp: expect.any(Number), + }, + ]; + if (systemPrompt) { + messages.push({ + content: 'my system prompt', + role: 'system', + }); + } + expect(createMock).toHaveBeenCalledWith({ + messages, + model: 'dummyModelFile', + stream: true, + }); + }, +); + +test('submit should send options', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + status: 'running', + health: { + Status: 'healthy', + }, + models: [ + { + id: 'dummyModelId', + file: { + file: 'dummyModelFile', + }, + }, + ], + connection: { + port: 8888, + }, + } as unknown as InferenceServer, + ]); + const createMock = vi.fn().mockResolvedValue([]); + vi.mocked(OpenAI).mockReturnValue({ + chat: { + completions: { + create: createMock, + }, + }, + } as unknown as OpenAI); + + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground('playground 1', { id: 'dummyModelId' } as ModelInfo, 'tracking-1'); + + const playgrounds = manager.getPlaygrounds(); + await manager.submit(playgrounds[0].id, 'dummyUserInput', '', { temperature: 0.123, max_tokens: 45, top_p: 0.345 }); + + const messages: unknown[] = [ + { + content: 'dummyUserInput', + id: expect.any(String), + role: 'user', + timestamp: expect.any(Number), + options: { + temperature: 0.123, + max_tokens: 45, + top_p: 0.345, + }, + }, + ]; + expect(createMock).toHaveBeenCalledWith({ + messages, + model: 'dummyModelFile', + stream: true, + temperature: 0.123, + max_tokens: 45, + top_p: 0.345, + }); +}); + +test('creating a new playground should send new playground to frontend', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([]); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground( + 'a name', + { + id: 'model-1', + name: 'Model 1', + } as unknown as ModelInfo, + 'tracking-1', + ); + expect(webviewMock.postMessage).toHaveBeenCalledWith({ + id: Messages.MSG_PLAYGROUNDS_V2_UPDATE, + body: [ + { + id: '0', + modelId: 'model-1', + name: 'a name', + }, + ], + }); +}); + +test('creating a new playground with no name should send new playground to frontend with generated name', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([]); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground( + '', + { + id: 'model-1', + name: 'Model 1', + } as unknown as ModelInfo, + 'tracking-1', + ); + expect(webviewMock.postMessage).toHaveBeenCalledWith({ + id: Messages.MSG_PLAYGROUNDS_V2_UPDATE, + body: [ + { + id: '0', + modelId: 'model-1', + name: 'playground 1', + }, + ], + }); +}); + +test('creating a new playground with no model served should start an inference server', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([]); + const createInferenceServerMock = vi.mocked(inferenceManagerMock.createInferenceServer); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground( + 'a name', + { + id: 'model-1', + name: 'Model 1', + } as unknown as ModelInfo, + 'tracking-1', + ); + expect(createInferenceServerMock).toHaveBeenCalledWith( + { + image: INFERENCE_SERVER_IMAGE, + labels: {}, + modelsInfo: [ + { + id: 'model-1', + name: 'Model 1', + }, + ], + port: expect.anything(), + }, + expect.anything(), + ); +}); + +test('creating a new playground with the model already served should not start an inference server', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + models: [ + { + id: 'model-1', + }, + ], + }, + ] as InferenceServer[]); + const createInferenceServerMock = vi.mocked(inferenceManagerMock.createInferenceServer); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground( + 'a name', + { + id: 'model-1', + name: 'Model 1', + } as unknown as ModelInfo, + 'tracking-1', + ); + expect(createInferenceServerMock).not.toHaveBeenCalled(); +}); + +test('creating a new playground with the model server stopped should start the inference server', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([ + { + models: [ + { + id: 'model-1', + }, + ], + status: 'stopped', + container: { + containerId: 'container-1', + }, + }, + ] as InferenceServer[]); + const createInferenceServerMock = vi.mocked(inferenceManagerMock.createInferenceServer); + const startInferenceServerMock = vi.mocked(inferenceManagerMock.startInferenceServer); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + await manager.createPlayground( + 'a name', + { + id: 'model-1', + name: 'Model 1', + } as unknown as ModelInfo, + 'tracking-1', + ); + expect(createInferenceServerMock).not.toHaveBeenCalled(); + expect(startInferenceServerMock).toHaveBeenCalledWith('container-1'); +}); + +test('delete conversation should delete the conversation', async () => { + vi.mocked(inferenceManagerMock.getServers).mockReturnValue([]); + + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + expect(manager.getConversations().length).toBe(0); + await manager.createPlayground( + 'a name', + { + id: 'model-1', + name: 'Model 1', + } as unknown as ModelInfo, + 'tracking-1', + ); + + const conversations = manager.getConversations(); + expect(conversations.length).toBe(1); + manager.deletePlayground(conversations[0].id); + expect(manager.getConversations().length).toBe(0); + expect(webviewMock.postMessage).toHaveBeenCalled(); +}); + +test('requestCreatePlayground should call createPlayground and createTask, then updateTask', async () => { + vi.useRealTimers(); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + const createTaskMock = vi.mocked(taskRegistryMock).createTask; + const updateTaskMock = vi.mocked(taskRegistryMock).updateTask; + createTaskMock.mockImplementation((_name: string, _state: string, labels: { [id: string]: string }) => { + return { + labels, + } as Task; + }); + const createPlaygroundSpy = vi.spyOn(manager, 'createPlayground').mockResolvedValue('playground-1'); + + const id = await manager.requestCreatePlayground('a name', { id: 'model-1' } as ModelInfo); + + expect(createPlaygroundSpy).toHaveBeenCalledWith('a name', { id: 'model-1' } as ModelInfo, expect.any(String)); + expect(createTaskMock).toHaveBeenCalledWith('Creating Playground environment', 'loading', { + trackingId: id, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(updateTaskMock).toHaveBeenCalledWith({ + labels: { + trackingId: id, + playgroundId: 'playground-1', + }, + state: 'success', + }); +}); + +test('requestCreatePlayground should call createPlayground and createTask, then updateTask when createPlayground fails', async () => { + vi.useRealTimers(); + const manager = new PlaygroundV2Manager(webviewMock, inferenceManagerMock, taskRegistryMock); + const createTaskMock = vi.mocked(taskRegistryMock).createTask; + const updateTaskMock = vi.mocked(taskRegistryMock).updateTask; + const getTasksByLabelsMock = vi.mocked(taskRegistryMock).getTasksByLabels; + createTaskMock.mockImplementation((_name: string, _state: string, labels: { [id: string]: string }) => { + return { + labels, + } as Task; + }); + const createPlaygroundSpy = vi.spyOn(manager, 'createPlayground').mockRejectedValue(new Error('an error')); + + const id = await manager.requestCreatePlayground('a name', { id: 'model-1' } as ModelInfo); + + expect(createPlaygroundSpy).toHaveBeenCalledWith('a name', { id: 'model-1' } as ModelInfo, expect.any(String)); + expect(createTaskMock).toHaveBeenCalledWith('Creating Playground environment', 'loading', { + trackingId: id, + }); + + getTasksByLabelsMock.mockReturnValue([ + { + labels: { + trackingId: id, + }, + } as unknown as Task, + ]); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(updateTaskMock).toHaveBeenCalledWith({ + error: 'Something went wrong while trying to create a playground environment Error: an error.', + labels: { + trackingId: id, + }, + state: 'error', + }); +}); diff --git a/packages/backend/src/managers/playgroundV2Manager.ts b/packages/backend/src/managers/playgroundV2Manager.ts new file mode 100644 index 000000000..118597d1b --- /dev/null +++ b/packages/backend/src/managers/playgroundV2Manager.ts @@ -0,0 +1,254 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { Disposable, Webview } from '@podman-desktop/api'; +import type { InferenceManager } from './inference/inferenceManager'; +import OpenAI from 'openai'; +import type { ChatCompletionChunk, ChatCompletionMessageParam } from 'openai/src/resources/chat/completions'; +import type { ModelOptions } from '@shared/src/models/IModelOptions'; +import type { Stream } from 'openai/streaming'; +import { ConversationRegistry } from '../registries/conversationRegistry'; +import type { Conversation, PendingChat, UserChat } from '@shared/src/models/IPlaygroundMessage'; +import type { PlaygroundV2 } from '@shared/src/models/IPlaygroundV2'; +import { Publisher } from '../utils/Publisher'; +import { Messages } from '@shared/Messages'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { withDefaultConfiguration } from '../utils/inferenceUtils'; +import { getRandomString } from '../utils/randomUtils'; +import type { TaskRegistry } from '../registries/TaskRegistry'; + +export class PlaygroundV2Manager extends Publisher implements Disposable { + #playgrounds: Map; + #conversationRegistry: ConversationRegistry; + #playgroundCounter = 0; + #UIDcounter: number; + + constructor( + webview: Webview, + private inferenceManager: InferenceManager, + private taskRegistry: TaskRegistry, + ) { + super(webview, Messages.MSG_PLAYGROUNDS_V2_UPDATE, () => this.getPlaygrounds()); + this.#playgrounds = new Map(); + this.#conversationRegistry = new ConversationRegistry(webview); + this.#UIDcounter = 0; + } + + deletePlayground(conversationId: string): void { + this.#conversationRegistry.deleteConversation(conversationId); + this.#playgrounds.delete(conversationId); + this.notify(); + } + + async requestCreatePlayground(name: string, model: ModelInfo): Promise { + const trackingId: string = getRandomString(); + const task = this.taskRegistry.createTask('Creating Playground environment', 'loading', { + trackingId: trackingId, + }); + + this.createPlayground(name, model, trackingId) + .then((playgroundId: string) => { + this.taskRegistry.updateTask({ + ...task, + state: 'success', + labels: { + ...task.labels, + playgroundId, + }, + }); + }) + .catch((err: unknown) => { + const tasks = this.taskRegistry.getTasksByLabels({ + trackingId: trackingId, + }); + // Filter the one no in loading state + tasks + .filter(t => t.state === 'loading' && t.id !== task.id) + .forEach(t => { + this.taskRegistry.updateTask({ + ...t, + state: 'error', + }); + }); + // Update the main task + this.taskRegistry.updateTask({ + ...task, + state: 'error', + error: `Something went wrong while trying to create a playground environment ${String(err)}.`, + }); + }); + return trackingId; + } + + async createPlayground(name: string, model: ModelInfo, trackingId: string): Promise { + const id = `${this.#playgroundCounter++}`; + + if (!name) { + name = this.getFreeName(); + } + + this.#conversationRegistry.createConversation(id); + + // create/start inference server if necessary + const servers = this.inferenceManager.getServers(); + const server = servers.find(s => s.models.map(mi => mi.id).includes(model.id)); + if (!server) { + await this.inferenceManager.createInferenceServer( + await withDefaultConfiguration({ + modelsInfo: [model], + }), + trackingId, + ); + } else if (server.status === 'stopped') { + await this.inferenceManager.startInferenceServer(server.container.containerId); + } + + this.#playgrounds.set(id, { + id, + name, + modelId: model.id, + }); + this.notify(); + + return id; + } + + getPlaygrounds(): PlaygroundV2[] { + return Array.from(this.#playgrounds.values()); + } + + private getUniqueId(): string { + return `playground-${++this.#UIDcounter}`; + } + + /** + * @param playgroundId + * @param userInput the user input + * @param options the model configuration + */ + async submit(playgroundId: string, userInput: string, systemPrompt: string, options?: ModelOptions): Promise { + const playground = this.#playgrounds.get(playgroundId); + if (playground === undefined) throw new Error('Playground not found.'); + + const servers = this.inferenceManager.getServers(); + const server = servers.find(s => s.models.map(mi => mi.id).includes(playground.modelId)); + if (server === undefined) throw new Error('Inference server not found.'); + + if (server.status !== 'running') throw new Error('Inference server is not running.'); + + if (server.health?.Status !== 'healthy') + throw new Error(`Inference server is not healthy, currently status: ${server.health.Status}.`); + + const modelInfo = server.models.find(model => model.id === playground.modelId); + if (modelInfo === undefined) + throw new Error( + `modelId '${playground.modelId}' is not available on the inference server, valid model ids are: ${server.models.map(model => model.id).join(', ')}.`, + ); + + const conversation = this.#conversationRegistry.get(playground.id); + if (conversation === undefined) throw new Error(`conversation with id ${playground.id} does not exist.`); + + this.#conversationRegistry.submit(conversation.id, { + content: userInput, + options: options, + role: 'user', + id: this.getUniqueId(), + timestamp: Date.now(), + } as UserChat); + + const client = new OpenAI({ + baseURL: `http://localhost:${server.connection.port}/v1`, + apiKey: 'dummy', + }); + + const messages = this.getFormattedMessages(playground.id); + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + client.chat.completions + .create({ + messages, + stream: true, + model: modelInfo.file.file, + ...options, + }) + .then(response => { + // process stream async + this.processStream(playground.id, response).catch((err: unknown) => { + console.error('Something went wrong while processing stream', err); + }); + }) + .catch((err: unknown) => { + console.error('Something went wrong while creating model reponse', err); + }); + } + + /** + * Given a Stream from the OpenAI library update and notify the publisher + * @param conversationId + * @param stream + */ + private async processStream(conversationId: string, stream: Stream): Promise { + const messageId = this.getUniqueId(); + this.#conversationRegistry.submit(conversationId, { + role: 'assistant', + choices: [], + completed: undefined, + id: messageId, + timestamp: Date.now(), + } as PendingChat); + + for await (const chunk of stream) { + this.#conversationRegistry.appendChoice(conversationId, messageId, { + content: chunk.choices[0]?.delta?.content || '', + }); + } + + this.#conversationRegistry.completeMessage(conversationId, messageId); + } + + /** + * Transform the ChatMessage interface to the OpenAI ChatCompletionMessageParam + * @private + */ + private getFormattedMessages(conversationId: string): ChatCompletionMessageParam[] { + return this.#conversationRegistry.get(conversationId).messages.map( + message => + ({ + name: undefined, + ...message, + }) as ChatCompletionMessageParam, + ); + } + + getConversations(): Conversation[] { + return this.#conversationRegistry.getAll(); + } + + dispose(): void { + this.#conversationRegistry.dispose(); + } + + getFreeName(): string { + let i = 0; + let name: string; + do { + name = `playground ${++i}`; + } while (this.getPlaygrounds().find(p => p.name === name)); + return name; + } +} diff --git a/packages/backend/src/managers/podmanConnection.spec.ts b/packages/backend/src/managers/podmanConnection.spec.ts new file mode 100644 index 000000000..015e4db31 --- /dev/null +++ b/packages/backend/src/managers/podmanConnection.spec.ts @@ -0,0 +1,134 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi } from 'vitest'; +import { PodmanConnection } from './podmanConnection'; +import type { RegisterContainerConnectionEvent, UpdateContainerConnectionEvent } from '@podman-desktop/api'; + +const mocks = vi.hoisted(() => ({ + getFirstRunningPodmanConnectionMock: vi.fn(), + onDidRegisterContainerConnection: vi.fn(), + onDidUpdateContainerConnection: vi.fn(), +})); + +vi.mock('@podman-desktop/api', async () => { + return { + provider: { + onDidRegisterContainerConnection: mocks.onDidRegisterContainerConnection, + onDidUpdateContainerConnection: mocks.onDidUpdateContainerConnection, + }, + }; +}); + +vi.mock('../utils/podman', () => { + return { + getFirstRunningPodmanConnection: mocks.getFirstRunningPodmanConnectionMock, + }; +}); + +test('startupSubscribe should execute immediately if provider already registered', async () => { + const manager = new PodmanConnection(); + // one provider is already registered + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue({ + connection: { + type: 'podman', + status: () => 'started', + }, + }); + mocks.onDidRegisterContainerConnection.mockReturnValue({ + dispose: vi.fn, + }); + manager.listenRegistration(); + const handler = vi.fn(); + manager.startupSubscribe(handler); + // the handler is called immediately + expect(handler).toHaveBeenCalledOnce(); +}); + +test('startupSubscribe should execute when provider is registered', async () => { + const manager = new PodmanConnection(); + + // no provider is already registered + mocks.getFirstRunningPodmanConnectionMock.mockReturnValue(undefined); + mocks.onDidRegisterContainerConnection.mockImplementation((f: (e: RegisterContainerConnectionEvent) => void) => { + setTimeout(() => { + f({ + connection: { + type: 'podman', + status: () => 'started', + }, + } as unknown as RegisterContainerConnectionEvent); + }, 1); + return { + dispose: vi.fn(), + }; + }); + manager.listenRegistration(); + const handler = vi.fn(); + manager.startupSubscribe(handler); + // the handler is not called immediately + expect(handler).not.toHaveBeenCalledOnce(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(handler).toHaveBeenCalledOnce(); +}); + +test('onMachineStart should call the handler when machine starts', async () => { + const manager = new PodmanConnection(); + mocks.onDidUpdateContainerConnection.mockImplementation((f: (e: UpdateContainerConnectionEvent) => void) => { + setTimeout(() => { + f({ + connection: { + type: 'podman', + }, + status: 'started', + } as UpdateContainerConnectionEvent); + }, 1); + return { + dispose: vi.fn(), + }; + }); + manager.listenMachine(); + const handler = vi.fn(); + manager.onMachineStart(handler); + expect(handler).not.toHaveBeenCalledOnce(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(handler).toHaveBeenCalledOnce(); +}); + +test('onMachineStop should call the handler when machine stops', async () => { + const manager = new PodmanConnection(); + mocks.onDidUpdateContainerConnection.mockImplementation((f: (e: UpdateContainerConnectionEvent) => void) => { + setTimeout(() => { + f({ + connection: { + type: 'podman', + }, + status: 'stopped', + } as UpdateContainerConnectionEvent); + }, 1); + return { + dispose: vi.fn(), + }; + }); + manager.listenMachine(); + const handler = vi.fn(); + manager.onMachineStop(handler); + expect(handler).not.toHaveBeenCalledOnce(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(handler).toHaveBeenCalledOnce(); +}); diff --git a/packages/backend/src/managers/podmanConnection.ts b/packages/backend/src/managers/podmanConnection.ts new file mode 100644 index 000000000..8b6be0641 --- /dev/null +++ b/packages/backend/src/managers/podmanConnection.ts @@ -0,0 +1,188 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { + type RegisterContainerConnectionEvent, + provider, + type UpdateContainerConnectionEvent, + containerEngine, + type PodInfo, + type Disposable, +} from '@podman-desktop/api'; +import { getFirstRunningPodmanConnection } from '../utils/podman'; + +export type startupHandle = () => void; +export type machineStartHandle = () => void; +export type machineStopHandle = () => void; +export type podStartHandle = (pod: PodInfo) => void; +export type podStopHandle = (pod: PodInfo) => void; +export type podRemoveHandle = (podId: string) => void; + +export class PodmanConnection implements Disposable { + #firstFound = false; + #toExecuteAtStartup: startupHandle[] = []; + #toExecuteAtMachineStop: machineStopHandle[] = []; + #toExecuteAtMachineStart: machineStartHandle[] = []; + #toExecuteAtPodStart: podStartHandle[] = []; + #toExecuteAtPodStop: podStopHandle[] = []; + #toExecuteAtPodRemove: podRemoveHandle[] = []; + + #onEventDisposable: Disposable | undefined; + + init(): void { + this.listenRegistration(); + this.listenMachine(); + this.watchPods(); + } + + dispose(): void { + this.#onEventDisposable?.dispose(); + } + + listenRegistration() { + // In case the extension has not yet registered, we listen for new registrations + // and retain the first started podman provider + const disposable = provider.onDidRegisterContainerConnection((e: RegisterContainerConnectionEvent) => { + if (e.connection.type !== 'podman' || e.connection.status() !== 'started') { + return; + } + if (this.#firstFound) { + return; + } + this.#firstFound = true; + for (const f of this.#toExecuteAtStartup) { + f(); + } + this.#toExecuteAtStartup = []; + disposable.dispose(); + }); + + // In case at least one extension has already registered, we get one started podman provider + const engine = getFirstRunningPodmanConnection(); + if (engine) { + disposable.dispose(); + this.#firstFound = true; + } + } + + // startupSubscribe registers f to be executed when a podman container provider + // registers, or immediately if already registered + startupSubscribe(f: startupHandle): void { + if (this.#firstFound) { + f(); + } else { + this.#toExecuteAtStartup.push(f); + } + } + + listenMachine() { + provider.onDidUpdateContainerConnection((e: UpdateContainerConnectionEvent) => { + if (e.connection.type !== 'podman') { + return; + } + if (e.status === 'stopped') { + for (const f of this.#toExecuteAtMachineStop) { + f(); + } + } else if (e.status === 'started') { + for (const f of this.#toExecuteAtMachineStart) { + f(); + } + } + }); + } + + onMachineStart(f: machineStartHandle) { + this.#toExecuteAtMachineStart.push(f); + } + + onMachineStop(f: machineStopHandle) { + this.#toExecuteAtMachineStop.push(f); + } + + watchPods() { + if (this.#onEventDisposable !== undefined) throw new Error('already watching pods.'); + + this.#onEventDisposable = containerEngine.onEvent(event => { + if (event.Type !== 'pod') { + return; + } + switch (event.status) { + case 'remove': + for (const f of this.#toExecuteAtPodRemove) { + f(event.id); + } + break; + case 'start': + if (!containerEngine.listPods) { + // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released + // and the extension minimal version is set to 1.8 + break; + } + containerEngine + .listPods() + .then((pods: PodInfo[]) => { + const pod = pods.find((p: PodInfo) => p.Id === event.id); + if (!pod) { + return; + } + for (const f of this.#toExecuteAtPodStart) { + f(pod); + } + }) + .catch((err: unknown) => { + console.error(err); + }); + break; + case 'stop': + if (!containerEngine.listPods) { + // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released + // and the extension minimal version is set to 1.8 + break; + } + containerEngine + .listPods() + .then((pods: PodInfo[]) => { + const pod = pods.find((p: PodInfo) => p.Id === event.id); + if (!pod) { + return; + } + for (const f of this.#toExecuteAtPodStop) { + f(pod); + } + }) + .catch((err: unknown) => { + console.error(err); + }); + break; + } + }); + } + + onPodStart(f: podStartHandle) { + this.#toExecuteAtPodStart.push(f); + } + + onPodStop(f: podStopHandle) { + this.#toExecuteAtPodStop.push(f); + } + + onPodRemove(f: podRemoveHandle) { + this.#toExecuteAtPodRemove.push(f); + } +} diff --git a/packages/backend/src/managers/snippets/java-okhttp-snippet.spec.ts b/packages/backend/src/managers/snippets/java-okhttp-snippet.spec.ts new file mode 100644 index 000000000..9cd3353a4 --- /dev/null +++ b/packages/backend/src/managers/snippets/java-okhttp-snippet.spec.ts @@ -0,0 +1,26 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test } from 'vitest'; +import { javaOkHttpGenerator } from './java-okhttp-snippet'; + +test('expect return generated snippet', async () => { + const payload = await javaOkHttpGenerator({ url: 'http://localhost:32412' }); + expect(payload).toBeDefined(); + expect(payload).toContain('.url("http://localhost:32412")'); +}); diff --git a/packages/backend/src/managers/snippets/java-okhttp-snippet.ts b/packages/backend/src/managers/snippets/java-okhttp-snippet.ts new file mode 100644 index 000000000..098a28451 --- /dev/null +++ b/packages/backend/src/managers/snippets/java-okhttp-snippet.ts @@ -0,0 +1,26 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { RequestOptions } from '@shared/src/models/RequestOptions'; +import mustache from 'mustache'; +import javaOkHttpTemplate from '../../templates/java-okhttp.mustache?raw'; + +export async function javaOkHttpGenerator(requestOptions: RequestOptions): Promise { + return mustache.render(javaOkHttpTemplate, { + endpoint: requestOptions.url, + }); +} diff --git a/packages/backend/src/managers/snippets/quarkus-snippet.spec.ts b/packages/backend/src/managers/snippets/quarkus-snippet.spec.ts new file mode 100644 index 000000000..4213e3619 --- /dev/null +++ b/packages/backend/src/managers/snippets/quarkus-snippet.spec.ts @@ -0,0 +1,43 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, expect, test, vi } from 'vitest'; +import { quarkusLangchain4Jgenerator } from './quarkus-snippet'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('expect fetched version in generated payload', async () => { + const oldFetch = global.fetch; + try { + global.fetch = vi.fn().mockResolvedValue({ + text: () => + new Promise(resolve => + resolve( + 'io.quarkiverse.langchain4jquarkus-langchain4j-corelatest-versionrelease-version', + ), + ), + }); + const payload = await quarkusLangchain4Jgenerator({ url: 'http://localhost:32412' }); + expect(payload).toBeDefined(); + expect(payload).toContain('release-version'); + } finally { + global.fetch = oldFetch; + } +}); diff --git a/packages/backend/src/managers/snippets/quarkus-snippet.ts b/packages/backend/src/managers/snippets/quarkus-snippet.ts new file mode 100644 index 000000000..5dd99d34b --- /dev/null +++ b/packages/backend/src/managers/snippets/quarkus-snippet.ts @@ -0,0 +1,43 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { RequestOptions } from '@shared/src/models/RequestOptions'; +import mustache from 'mustache'; +import template from '../../templates/quarkus-langchain4j.mustache?raw'; +import xmljs from 'xml-js'; + +const SUFFIX_LENGTH = '/chat/completions'.length; + +const METADATA_URL = + 'https://repo1.maven.org/maven2/io/quarkiverse/langchain4j/quarkus-langchain4j-core/maven-metadata.xml'; + +let quarkusLangchain4jVersion: string; + +async function getQuarkusLangchain4jVersion(): Promise { + if (quarkusLangchain4jVersion) { + return quarkusLangchain4jVersion; + } + const response = await fetch(METADATA_URL, { redirect: 'follow' }); + const content = JSON.parse(xmljs.xml2json(await response.text(), { compact: true })); + return (quarkusLangchain4jVersion = content.metadata.versioning.release._text); +} +export async function quarkusLangchain4Jgenerator(requestOptions: RequestOptions): Promise { + return mustache.render(template, { + baseUrl: requestOptions.url.substring(0, requestOptions.url.length - SUFFIX_LENGTH), + version: await getQuarkusLangchain4jVersion(), + }); +} diff --git a/packages/backend/src/models/AIConfig.spec.ts b/packages/backend/src/models/AIConfig.spec.ts new file mode 100644 index 000000000..efc887b5e --- /dev/null +++ b/packages/backend/src/models/AIConfig.spec.ts @@ -0,0 +1,71 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, describe, vi } from 'vitest'; +import fs from 'fs'; +import { type AIConfig, parseYamlFile } from './AIConfig'; + +// Define mock file paths and contents +const mockYamlPath = '/path/to/mock.yml'; +const mockYamlContent = ` +application: + containers: + - name: container1 + contextdir: /path/to/dir1 + arch: ["x86"] + model-service: true + gpu-env: ["env1", "env2"] + ports: [ 8080 ] + - name: container2 + arch: ["arm"] + ports: [ 8001 ] +`; + +const readFileSync = vi.spyOn(fs, 'readFileSync'); + +describe('parseYaml', () => { + test('should parse valid YAML file', () => { + readFileSync.mockReturnValue(mockYamlContent); + + const defaultArch = 'x64'; + const expectedConfig: AIConfig = { + application: { + containers: [ + { + name: 'container1', + contextdir: '/path/to/dir1', + arch: ['x86'], + modelService: true, + gpu_env: ['env1', 'env2'], + ports: [8080], + }, + { + name: 'container2', + contextdir: '.', + arch: ['arm'], + modelService: false, + gpu_env: [], + ports: [8001], + }, + ], + }, + }; + + expect(parseYamlFile(mockYamlPath, defaultArch)).toEqual(expectedConfig); + }); +}); diff --git a/packages/backend/src/models/AIConfig.ts b/packages/backend/src/models/AIConfig.ts index e0e573cfc..3b24b35b3 100644 --- a/packages/backend/src/models/AIConfig.ts +++ b/packages/backend/src/models/AIConfig.ts @@ -17,13 +17,17 @@ ***********************************************************************/ import * as jsYaml from 'js-yaml'; +import fs from 'fs'; export interface ContainerConfig { name: string; contextdir: string; containerfile?: string; - arch: string; + arch: string[]; modelService: boolean; + gpu_env: string[]; + ports?: number[]; + image?: string; } export interface AIConfig { application: { @@ -45,7 +49,9 @@ export function assertString(value: unknown): string { throw new Error('value not a string'); } -export function parseYaml(raw: string, defaultArch: string): AIConfig { +export function parseYamlFile(filepath: string, defaultArch: string): AIConfig { + const raw: string = fs.readFileSync(filepath, 'utf-8'); + const aiStudioConfig = jsYaml.load(raw); const application = aiStudioConfig?.['application']; if (!application) throw new Error('AIConfig has bad formatting.'); @@ -54,13 +60,27 @@ export function parseYaml(raw: string, defaultArch: string): AIConfig { return { application: { - containers: containers.map(container => ({ - arch: isString(container['arch']) ? container['arch'] : defaultArch, - modelService: container['model-service'] === true, - containerfile: isString(container['containerfile']) ? container['containerfile'] : undefined, - contextdir: assertString(container['contextdir']), - name: assertString(container['name']), - })), + containers: containers.map(container => { + if (typeof container !== 'object') throw new Error('containers array malformed'); + + let contextdir: string; + if ('contextdir' in container) { + contextdir = assertString(container['contextdir']); + } else { + contextdir = '.'; + } + + return { + arch: Array.isArray(container['arch']) ? container['arch'] : [defaultArch], + modelService: container['model-service'] === true, + containerfile: isString(container['containerfile']) ? container['containerfile'] : undefined, + contextdir: contextdir, + name: assertString(container['name']), + gpu_env: Array.isArray(container['gpu-env']) ? container['gpu-env'] : [], + ports: Array.isArray(container['ports']) ? container['ports'] : [], + image: isString(container['image']) ? container['image'] : undefined, + }; + }), }, }; } diff --git a/packages/backend/src/models/baseEvent.ts b/packages/backend/src/models/baseEvent.ts new file mode 100644 index 000000000..16c4200ab --- /dev/null +++ b/packages/backend/src/models/baseEvent.ts @@ -0,0 +1,49 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface BaseEvent { + id: string; + status: 'error' | 'completed' | 'progress' | 'canceled'; + message?: string; +} + +export interface CompletionEvent extends BaseEvent { + status: 'completed' | 'error' | 'canceled'; + duration: number; +} + +export interface ProgressEvent extends BaseEvent { + status: 'progress'; + value: number; +} + +export const isCompletionEvent = (value: unknown): value is CompletionEvent => { + return ( + !!value && + typeof value === 'object' && + 'status' in value && + typeof value['status'] === 'string' && + ['canceled', 'completed', 'error'].includes(value['status']) + ); +}; + +export const isProgressEvent = (value: unknown): value is ProgressEvent => { + return ( + !!value && typeof value === 'object' && 'status' in value && value['status'] === 'progress' && 'value' in value + ); +}; diff --git a/packages/backend/src/recipe-catalog.json b/packages/backend/src/recipe-catalog.json deleted file mode 100644 index f16e4aed4..000000000 --- a/packages/backend/src/recipe-catalog.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "categories" : [ - { - "description" : "Models that work with text: classify, summarize, translate, or generate text.", - "name" : "Natural Language Processing", - "recipes" : [ - { - "applications" : [], - "description" : "Is that string token a date, a name, a place, a verb? These models can tell you!", - "name" : "Token classification" - }, - { - "applications" : [], - "description" : "Determine if text data is positive, negative, or neutral.", - "name" : "Sentiment Analysis" - }, - { - "applications" : [], - "description" : "Generate a narrative text based on a prompt.", - "name" : "Story Generation" - }, - { - "applications" : [], - "description" : "Generate a TLDR; text summary for a large set of text data.", - "name" : "Text summarization" - }, - { - "applications" : [], - "description" : "Offer your users auto-complete suggestions based on their text input.", - "name" : "Auto-complete suggestions" - }, - { - "applications" : [], - "description" : "Offer data from a data table in response to natural language questions.", - "name" : "Tabular data answer lookup" - } - ] - }, - { - "description" : "Process images, from classification to object detection and segmentation.", - "name" : "Computer Vision", - "recipes" : [ - { - "applications" : [], - "description" : "Is that image a person, a cat, or a landscape? Generate keyword labels.", - "name" : "Image classification" - }, - { - "applications" : [], - "description" : "Is it a bird, is it a plane, is it a superhero? Detect specific objects in an image.", - "name" : "Object detection" - }, - { - "applications" : [], - "description" : "Segment an image into foreground and background and remove the background.", - "name" : "Image background removal" - }, - { - "applications" : [], - "description" : "Bring color to black & white or historical photography.", - "name" : "Image colorization" - }, - { - "applications" : [], - "description" : "Increase the dimensions of an image to higher resolution.", - "name" : "Image resolution improvement" - }, - { - "applications" : [], - "description" : "Remove unwanted objects (including dust / scratches) from an image.", - "name" : "Remove unwanted objects" - }, - { - "applications" : [], - "description" : "Recreate an image to mimic the style of another.", - "name" : "Style transfer" - }, - { - "applications" : [], - "description" : "Create a 3-dimensional representation of a 2-dimensional image.", - "name" : "2D to 3D" - }, - { - "applications" : [ - { - "appStack" : { - "tags" : [] - }, - "description" : "Ready to get images of your own doggo (or other loved pet!) into this sample application? You'll need to retrain the model. Check out the Retrain tab to get started.", - "images" : [], - "input" : {}, - "models" : [ - { - "name" : "stable-diffusion2", - "rating" : "3 stars", - "retrain" : { - "dataset" : { - "reqs" : [ - { - "description" : "800 x 600 pixels", - "name" : "Dimensions" - }, - { - "description" : "*.PNG", - "name" : "File Format" - }, - { - "description" : "RGB", - "name" : "Colorspace" - }, - { - "description" : "8-bit", - "name" : "Bitdepth" - } - ], - "sampleDataset" : { - "icon" : "$APPDATA/ai-studio/recipes/provider-icons/github.png", - "provider" : "GitHub", - "url" : "https://github.com/redhat-ai/dog-on-the-moon/my-doggo" - } - }, - "description" : "Ready to get images of your own doggo (or other loved pet!) into this sample application? You'll need to retrain the model. Check out the Retrain tab to get started.", - "headline" : "Retrain with your own images", - "restrainable" : true, - "trainingEnv" : { - "description" : "You can pull and run the model training environment in Podman Desktop", - "size" : "1.23 GB", - "tag" : "latest", - "url" : "https://quay.io/redhat-ai/stable-diffusion2-model-env" - } - }, - "status" : "Default", - "tags" : [ - { - "name" : "Image", - "Type" : "Category" - }, - { - "name" : "Meta License", - "Type" : "License" - } - ] - }, - { - "name" : "unstable-diffusion3", - "rating" : "3 stars", - "retrain" : { - "description" : "Ready to get images of your own doggo (or other loved pet!) into this sample application? You'll need to retrain the model. Check out the Retrain tab to get started.", - "headline" : "Retrain with your own images", - "restrainable" : true - }, - "status" : "Non-default", - "tags" : [ - { - "name" : "Image", - "Type" : "Category" - }, - { - "name" : "MIT License", - "Type" : "License" - } - ] - } - ], - "name" : "stable-diffusion2-model", - "output" : {}, - "repo" : {}, - "thumbnails" : [] - } - ], - "description" : "Generate images based on a text prompt. Create a new image composition.", - "name" : "Image generation" - }, - { - "applications" : [], - "description" : "Extract text from an image.", - "name" : "Text extract" - } - ] - }, - { - "description" : "Recognize speech or classify audio with audio models.", - "name" : "Audio", - "recipes" : [] - }, - { - "description" : "Stuff about multimodal models goes here omfg yes amazing.", - "name" : "Multimodal", - "recipes" : [] - } - ] -} diff --git a/packages/backend/src/registries/ApplicationRegistry.ts b/packages/backend/src/registries/ApplicationRegistry.ts new file mode 100644 index 000000000..c0fb17bcf --- /dev/null +++ b/packages/backend/src/registries/ApplicationRegistry.ts @@ -0,0 +1,55 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { RecipeModelIndex } from '@shared/src/models/IRecipeModelIndex'; + +export class ApplicationRegistry { + #applications = new Map(); + + keys(): RecipeModelIndex[] { + return Array.from(this.#applications.values()).map(a => ({ recipeId: a.recipeId, modelId: a.modelId })); + } + + has(recipeModel: RecipeModelIndex): boolean { + return this.#applications.has(this.hash(recipeModel)); + } + + delete(recipeModel: RecipeModelIndex): boolean { + return this.#applications.delete(this.hash(recipeModel)); + } + + values(): IterableIterator { + return this.#applications.values(); + } + + get(recipeModel: RecipeModelIndex): T { + return this.#applications.get(this.hash(recipeModel)); + } + + set(recipeModel: RecipeModelIndex, value: T): void { + this.#applications.set(this.hash(recipeModel), value); + } + + clear() { + this.#applications.clear(); + } + + private hash(recipeModel: RecipeModelIndex): string { + return recipeModel.recipeId + recipeModel.modelId; + } +} diff --git a/packages/backend/src/registries/ContainerRegistry.spec.ts b/packages/backend/src/registries/ContainerRegistry.spec.ts new file mode 100644 index 000000000..1adf180f4 --- /dev/null +++ b/packages/backend/src/registries/ContainerRegistry.spec.ts @@ -0,0 +1,164 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { beforeEach, expect, test, vi } from 'vitest'; +import { ContainerRegistry } from './ContainerRegistry'; +import { type ContainerJSONEvent, EventEmitter } from '@podman-desktop/api'; + +const mocks = vi.hoisted(() => ({ + onEventMock: vi.fn(), + DisposableCreateMock: vi.fn(), +})); + +vi.mock('@podman-desktop/api', async () => { + return { + EventEmitter: vi.fn(), + Disposable: { + create: mocks.DisposableCreateMock, + }, + containerEngine: { + onEvent: mocks.onEventMock, + }, + }; +}); + +beforeEach(() => { + const listeners: ((value: unknown) => void)[] = []; + const eventSubscriber = (listener: (value: unknown) => void) => { + listeners.push(listener); + }; + const fire = (value: unknown) => { + listeners.forEach(listener => listener(value)); + }; + vi.mocked(EventEmitter).mockReturnValue({ + event: eventSubscriber, + fire: fire, + } as unknown as EventEmitter); +}); + +test('ContainerRegistry init', () => { + const registry = new ContainerRegistry(); + registry.init(); + + expect(mocks.onEventMock).toHaveBeenCalledOnce(); +}); + +test('ContainerRegistry subscribe', () => { + // Get the callback created by the ContainerRegistry + let callback: (event: ContainerJSONEvent) => void; + mocks.onEventMock.mockImplementation((method: (event: ContainerJSONEvent) => void) => { + callback = method; + }); + + // Create the ContainerRegistry and init + const registry = new ContainerRegistry(); + registry.init(); + + // Let's create a dummy subscriber + let subscribedStatus: undefined | string = undefined; + registry.subscribe('random', (status: string) => { + subscribedStatus = status; + }); + + // Generate a fake event + callback({ + status: 'die', + id: 'random', + type: 'container', + }); + + expect(subscribedStatus).toBe('die'); + expect(mocks.DisposableCreateMock).toHaveBeenCalledOnce(); +}); + +test('ContainerRegistry unsubscribe all if container remove', () => { + // Get the callback created by the ContainerRegistry + let callback: (event: ContainerJSONEvent) => void; + mocks.onEventMock.mockImplementation((method: (event: ContainerJSONEvent) => void) => { + callback = method; + }); + + // Create the ContainerRegistry and init + const registry = new ContainerRegistry(); + registry.init(); + + // Let's create a dummy subscriber + const subscribeMock = vi.fn(); + registry.subscribe('random', subscribeMock); + + // Generate a remove event + callback({ status: 'remove', id: 'random', type: 'container' }); + + // Call it a second time + callback({ status: 'remove', id: 'random', type: 'container' }); + + // Our subscriber should only have been called once, the first, after it should have been removed. + expect(subscribeMock).toHaveBeenCalledOnce(); +}); + +test('ContainerRegistry subscriber disposed should not be called', () => { + // Get the callback created by the ContainerRegistry + let callback: (event: ContainerJSONEvent) => void; + mocks.onEventMock.mockImplementation((method: (event: ContainerJSONEvent) => void) => { + callback = method; + }); + + mocks.DisposableCreateMock.mockImplementation(callback => ({ + dispose: () => callback(), + })); + + // Create the ContainerRegistry and init + const registry = new ContainerRegistry(); + registry.init(); + + // Let's create a dummy subscriber + const subscribeMock = vi.fn(); + const disposable = registry.subscribe('random', subscribeMock); + disposable.dispose(); + + // Generate a random event + callback({ status: 'die', id: 'random', type: 'container' }); + + // never should have been called + expect(subscribeMock).toHaveBeenCalledTimes(0); +}); + +test('ContainerRegistry should fire ContainerStart when container start', () => { + // Get the callback created by the ContainerRegistry + let callback: (event: ContainerJSONEvent) => void; + mocks.onEventMock.mockImplementation((method: (event: ContainerJSONEvent) => void) => { + callback = method; + }); + + // Create the ContainerRegistry and init + const registry = new ContainerRegistry(); + registry.init(); + + const startListenerMock = vi.fn(); + registry.onStartContainerEvent(startListenerMock); + + // Generate a remove event + callback({ status: 'remove', id: 'random', type: 'container' }); + + expect(startListenerMock).not.toHaveBeenCalled(); + + // Call it a second time + callback({ status: 'start', id: 'random', type: 'container' }); + + // Our subscriber should only have been called once, the first, after it should have been removed. + expect(startListenerMock).toHaveBeenCalledOnce(); +}); diff --git a/packages/backend/src/registries/ContainerRegistry.ts b/packages/backend/src/registries/ContainerRegistry.ts new file mode 100644 index 000000000..a1622d5f6 --- /dev/null +++ b/packages/backend/src/registries/ContainerRegistry.ts @@ -0,0 +1,75 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import * as podmanDesktopApi from '@podman-desktop/api'; + +export type Subscriber = { + id: number; + callback: (status: string) => void; +}; + +export interface ContainerStart { + id: string; +} + +export class ContainerRegistry { + private count: number = 0; + private subscribers: Map = new Map(); + + private readonly _onStartContainerEvent = new podmanDesktopApi.EventEmitter(); + readonly onStartContainerEvent: podmanDesktopApi.Event = this._onStartContainerEvent.event; + + init(): podmanDesktopApi.Disposable { + return podmanDesktopApi.containerEngine.onEvent(event => { + if (event.status === 'start') { + this._onStartContainerEvent.fire({ + id: event.id, + }); + } + + if (this.subscribers.has(event.id)) { + this.subscribers.get(event.id).forEach(subscriber => subscriber.callback(event.status)); + + // If the event type is remove, we dispose all subscribers for the specific containers + if (event.status === 'remove') { + this.subscribers.delete(event.id); + } + } + }); + } + + subscribe(containerId: string, callback: (status: string) => void): podmanDesktopApi.Disposable { + const existing: Subscriber[] = this.subscribers.has(containerId) ? this.subscribers.get(containerId) : []; + const subscriberId = ++this.count; + this.subscribers.set(containerId, [ + { + id: subscriberId, + callback: callback, + }, + ...existing, + ]); + + return podmanDesktopApi.Disposable.create(() => { + if (!this.subscribers.has(containerId)) return; + + this.subscribers.set( + containerId, + this.subscribers.get(containerId).filter(subscriber => subscriber.id !== subscriberId), + ); + }); + } +} diff --git a/packages/backend/src/registries/LocalRepositoryRegistry.spec.ts b/packages/backend/src/registries/LocalRepositoryRegistry.spec.ts new file mode 100644 index 000000000..52b6aa395 --- /dev/null +++ b/packages/backend/src/registries/LocalRepositoryRegistry.spec.ts @@ -0,0 +1,70 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { beforeEach, expect, test, vi } from 'vitest'; +import { LocalRepositoryRegistry } from './LocalRepositoryRegistry'; +import { Messages } from '@shared/Messages'; +import type { Webview } from '@podman-desktop/api'; + +const mocks = vi.hoisted(() => ({ + DisposableCreateMock: vi.fn(), + postMessageMock: vi.fn(), +})); + +vi.mock('@podman-desktop/api', async () => { + return { + Disposable: { + create: mocks.DisposableCreateMock, + }, + }; +}); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.postMessageMock.mockResolvedValue(undefined); +}); + +test('should not have any repositories by default', () => { + const localRepositories = new LocalRepositoryRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + expect(localRepositories.getLocalRepositories().length).toBe(0); +}); + +test('should notify webview when register', () => { + const localRepositories = new LocalRepositoryRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + localRepositories.register({ path: 'random', labels: { 'recipe-id': 'random' } }); + expect(mocks.postMessageMock).toHaveBeenNthCalledWith(1, { + id: Messages.MSG_LOCAL_REPOSITORY_UPDATE, + body: [{ path: 'random', labels: { 'recipe-id': 'random' } }], + }); +}); + +test('should notify webview when unregister', () => { + const localRepositories = new LocalRepositoryRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + localRepositories.register({ path: 'random', labels: { 'recipe-id': 'random' } }); + localRepositories.unregister('random'); + + expect(mocks.postMessageMock).toHaveBeenLastCalledWith({ + id: Messages.MSG_LOCAL_REPOSITORY_UPDATE, + body: [], + }); +}); diff --git a/packages/backend/src/registries/LocalRepositoryRegistry.ts b/packages/backend/src/registries/LocalRepositoryRegistry.ts new file mode 100644 index 000000000..83abd912b --- /dev/null +++ b/packages/backend/src/registries/LocalRepositoryRegistry.ts @@ -0,0 +1,51 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { LocalRepository } from '@shared/src/models/ILocalRepository'; +import { Messages } from '@shared/Messages'; +import { type Webview, Disposable } from '@podman-desktop/api'; +import { Publisher } from '../utils/Publisher'; + +/** + * The LocalRepositoryRegistry is responsible for keeping track of the directories where recipe are cloned + */ +export class LocalRepositoryRegistry extends Publisher { + // Map path => LocalRepository + private repositories: Map = new Map(); + + constructor(webview: Webview) { + super(webview, Messages.MSG_LOCAL_REPOSITORY_UPDATE, () => this.getLocalRepositories()); + } + + register(localRepository: LocalRepository): Disposable { + this.repositories.set(localRepository.path, localRepository); + this.notify(); + + return Disposable.create(() => { + this.unregister(localRepository.path); + }); + } + + unregister(path: string): void { + this.repositories.delete(path); + this.notify(); + } + + getLocalRepositories(): LocalRepository[] { + return Array.from(this.repositories.values()); + } +} diff --git a/packages/backend/src/registries/RecipeStatusRegistry.ts b/packages/backend/src/registries/RecipeStatusRegistry.ts deleted file mode 100644 index e614bec27..000000000 --- a/packages/backend/src/registries/RecipeStatusRegistry.ts +++ /dev/null @@ -1,57 +0,0 @@ -/********************************************************************** - * Copyright (C) 2024 Red Hat, Inc. - * - * 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. - * - * SPDX-License-Identifier: Apache-2.0 - ***********************************************************************/ - -import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; -import type { TaskRegistry } from './TaskRegistry'; -import type { Webview } from '@podman-desktop/api'; -import { MSG_NEW_RECIPE_STATE } from '@shared/Messages'; - -export class RecipeStatusRegistry { - private statuses: Map = new Map(); - - constructor( - private taskRegistry: TaskRegistry, - private webview: Webview, - ) {} - - setStatus(recipeId: string, status: RecipeStatus) { - // Update the TaskRegistry - if (status.tasks && status.tasks.length > 0) { - status.tasks.map(task => this.taskRegistry.set(task)); - } - this.statuses.set(recipeId, status); - this.dispatchState().catch((err: unknown) => { - console.error('error dispatching recipe statuses', err); - }); // we don't want to wait - } - - getStatus(recipeId: string): RecipeStatus | undefined { - return this.statuses.get(recipeId); - } - - getStatuses(): Map { - return this.statuses; - } - - private async dispatchState() { - await this.webview.postMessage({ - id: MSG_NEW_RECIPE_STATE, - body: this.statuses, - }); - } -} diff --git a/packages/backend/src/registries/TaskRegistry.spec.ts b/packages/backend/src/registries/TaskRegistry.spec.ts new file mode 100644 index 000000000..41a688fff --- /dev/null +++ b/packages/backend/src/registries/TaskRegistry.spec.ts @@ -0,0 +1,107 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { Webview } from '@podman-desktop/api'; +import { TaskRegistry } from './TaskRegistry'; + +const mocks = vi.hoisted(() => ({ + postMessageMock: vi.fn(), +})); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.postMessageMock.mockResolvedValue(undefined); +}); + +test('should not have any tasks by default', () => { + const taskRegistry = new TaskRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + expect(taskRegistry.getTasks().length).toBe(0); +}); + +test('should notify when create task', () => { + const taskRegistry = new TaskRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + + taskRegistry.createTask('random', 'loading'); + + expect(mocks.postMessageMock).toHaveBeenCalled(); +}); + +test('should notify when update task', () => { + const taskRegistry = new TaskRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + + const task = taskRegistry.createTask('random', 'loading'); + taskRegistry.updateTask(task); + + expect(mocks.postMessageMock).toHaveBeenCalledTimes(2); +}); + +test('should get tasks by label', () => { + const taskRegistry = new TaskRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + + taskRegistry.createTask('random-1', 'loading', { index: '1' }); + taskRegistry.createTask('random-2', 'loading', { index: '2' }); + + const tasksWithIndex1 = taskRegistry.getTasksByLabels({ index: '1' }); + const tasksWithIndex2 = taskRegistry.getTasksByLabels({ index: '2' }); + + expect(tasksWithIndex1.length).toBe(1); + expect(tasksWithIndex2.length).toBe(1); + expect(tasksWithIndex1[0].name).toBe('random-1'); + expect(tasksWithIndex2[0].name).toBe('random-2'); +}); + +test('should delete tasks by label', () => { + const taskRegistry = new TaskRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + + taskRegistry.createTask('random-1', 'loading', { index: '1' }); + taskRegistry.createTask('random-2', 'loading', { index: '2' }); + + taskRegistry.deleteByLabels({ index: '1' }); + + expect(taskRegistry.getTasks().length).toBe(1); + expect(taskRegistry.getTasks()[0].name).toBe('random-2'); +}); + +test('should get tasks by multiple labels', () => { + const taskRegistry = new TaskRegistry({ + postMessage: mocks.postMessageMock, + } as unknown as Webview); + + taskRegistry.createTask('task-1', 'loading', { type: 'A', priority: 'high' }); + taskRegistry.createTask('task-2', 'loading', { type: 'B', priority: 'low' }); + taskRegistry.createTask('task-3', 'loading', { type: 'A', priority: 'medium' }); + + const tasksWithTypeA = taskRegistry.getTasksByLabels({ type: 'A' }); + const tasksWithHighPriority = taskRegistry.getTasksByLabels({ priority: 'high' }); + const tasksWithTypeAAndHighPriority = taskRegistry.getTasksByLabels({ type: 'A', priority: 'high' }); + + expect(tasksWithTypeA.length).toBe(2); + expect(tasksWithHighPriority.length).toBe(1); + expect(tasksWithTypeAAndHighPriority.length).toBe(1); + expect(tasksWithTypeAAndHighPriority[0].name).toBe('task-1'); +}); diff --git a/packages/backend/src/registries/TaskRegistry.ts b/packages/backend/src/registries/TaskRegistry.ts index 31bea2a72..3c9566989 100644 --- a/packages/backend/src/registries/TaskRegistry.ts +++ b/packages/backend/src/registries/TaskRegistry.ts @@ -16,16 +16,135 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { Task } from '@shared/src/models/ITask'; +import type { Task, TaskState } from '@shared/src/models/ITask'; +import { Messages } from '@shared/Messages'; +import type { Webview } from '@podman-desktop/api'; +/** + * A registry for managing tasks. + */ export class TaskRegistry { + private counter: number = 0; private tasks: Map = new Map(); - set(task: Task) { + /** + * Constructs a new TaskRegistry. + * @param webview The webview instance to use for communication. + */ + constructor(private webview: Webview) {} + + /** + * Retrieves a task by its ID. + * @param id The ID of the task to retrieve. + * @returns The task with the specified ID, or undefined if not found. + */ + get(id: string): Task | undefined { + if (this.tasks.has(id)) return this.tasks.get(id); + return undefined; + } + + /** + * Creates a new task. + * @param name The name of the task. + * @param state The initial state of the task. + * @param labels Optional labels for the task. + * @returns The newly created task. + */ + createTask(name: string, state: TaskState, labels: { [id: string]: string } = {}): Task { + const task = { + id: `task-${++this.counter}`, + name: name, + state: state, + labels: labels, + }; this.tasks.set(task.id, task); + this.notify(); + return task; } + /** + * Updates an existing task. + * @param task The task to update. + * @throws Error if the task with the specified ID does not exist. + */ + updateTask(task: Task) { + if (!this.tasks.has(task.id)) throw new Error(`Task with id ${task.id} does not exist.`); + this.tasks.set(task.id, { + ...task, + state: task.error !== undefined ? 'error' : task.state, // enforce error state when error is defined + }); + this.notify(); + } + + /** + * Deletes a task by its ID. + * @param taskId The ID of the task to delete. + */ delete(taskId: string) { - this.tasks.delete(taskId); + this.deleteAll([taskId]); + } + + /** + * Deletes multiple tasks by their IDs. + * @param taskIds The IDs of the tasks to delete. + */ + deleteAll(taskIds: string[]) { + taskIds.map(taskId => this.tasks.delete(taskId)); + this.notify(); + } + + /** + * Retrieves all tasks. + * @returns An array of all tasks. + */ + getTasks(): Task[] { + return Array.from(this.tasks.values()); + } + + /** + * Retrieves tasks that match the specified labels. + * @param requestedLabels The labels to match against. + * @returns An array of tasks that match the specified labels. + */ + getTasksByLabels(requestedLabels: { [key: string]: string }): Task[] { + return this.getTasks().filter(task => this.filter(task, requestedLabels)); + } + + /** + * Return the first task matching all the labels provided + * @param requestedLabels + */ + findTaskByLabels(requestedLabels: { [key: string]: string }): Task | undefined { + return this.getTasks().find(task => this.filter(task, requestedLabels)); + } + + private filter(task: Task, requestedLabels: { [key: string]: string }): boolean { + const labels = task.labels; + if (labels === undefined) return false; + + for (const [key, value] of Object.entries(requestedLabels)) { + if (!(key in labels) || labels[key] !== value) return false; + } + + return true; + } + + /** + * Deletes tasks that match the specified labels. + * @param labels The labels to match against for deletion. + */ + deleteByLabels(labels: { [key: string]: string }): void { + this.deleteAll(this.getTasksByLabels(labels).map(task => task.id)); + } + + private notify() { + this.webview + .postMessage({ + id: Messages.MSG_TASKS_UPDATE, + body: this.getTasks(), + }) + .catch((err: unknown) => { + console.error('error notifying tasks', err); + }); } } diff --git a/packages/backend/src/registries/conversationRegistry.ts b/packages/backend/src/registries/conversationRegistry.ts new file mode 100644 index 000000000..16664e677 --- /dev/null +++ b/packages/backend/src/registries/conversationRegistry.ts @@ -0,0 +1,160 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { Publisher } from '../utils/Publisher'; +import type { + AssistantChat, + ChatMessage, + Choice, + Conversation, + PendingChat, +} from '@shared/src/models/IPlaygroundMessage'; +import type { Disposable, Webview } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; + +export class ConversationRegistry extends Publisher implements Disposable { + #conversations: Map; + #counter: number; + + constructor(webview: Webview) { + super(webview, Messages.MSG_CONVERSATIONS_UPDATE, () => this.getAll()); + this.#conversations = new Map(); + this.#counter = 0; + } + + init(): void { + // TODO: load from file + } + + private getUniqueId(): string { + return `conversation-${++this.#counter}`; + } + + /** + * Utility method to update a message content in a given conversation + * @param conversationId + * @param messageId + * @param message + */ + update(conversationId: string, messageId: string, message: Partial) { + const conversation = this.#conversations.get(conversationId); + + if (conversation === undefined) { + throw new Error(`conversation with id ${conversationId} does not exist.`); + } + + const messageIndex = conversation.messages.findIndex(message => message.id === messageId); + if (messageIndex === -1) + throw new Error(`message with id ${messageId} does not exist in conversation ${conversationId}.`); + + // Update the message with the provided content + conversation.messages[messageIndex] = { + ...conversation.messages[messageIndex], + ...message, + id: messageId, // preventing we are not updating the id + }; + this.notify(); + } + + deleteConversation(id: string): void { + this.#conversations.delete(id); + this.notify(); + } + + createConversation(id: string): void { + this.#conversations.set(id, { + messages: [], + id, + }); + this.notify(); + } + + /** + * This method will be responsible for finalizing the message by concatenating all the choices + * @param conversationId + * @param messageId + */ + completeMessage(conversationId: string, messageId: string): void { + const conversation = this.#conversations.get(conversationId); + if (conversation === undefined) throw new Error(`conversation with id ${conversationId} does not exist.`); + + const messageIndex = conversation.messages.findIndex(message => message.id === messageId); + if (messageIndex === -1) + throw new Error(`message with id ${messageId} does not exist in conversation ${conversationId}.`); + + const content = ((conversation.messages[messageIndex] as PendingChat)?.choices || []) + .map(choice => choice.content) + .join(''); + + this.update(conversationId, messageId, { + ...conversation.messages[messageIndex], + choices: undefined, + role: 'assistant', + completed: Date.now(), + content: content, + } as AssistantChat); + } + + /** + * Utility method to quickly add a choice to a given a message inside a conversation + * @param conversationId + * @param messageId + * @param choice + */ + appendChoice(conversationId: string, messageId: string, choice: Choice): void { + const conversation = this.#conversations.get(conversationId); + if (conversation === undefined) throw new Error(`conversation with id ${conversationId} does not exist.`); + + const messageIndex = conversation.messages.findIndex(message => message.id === messageId); + if (messageIndex === -1) + throw new Error(`message with id ${messageId} does not exist in conversation ${conversationId}.`); + + this.update(conversationId, messageId, { + ...conversation.messages[messageIndex], + choices: [...((conversation.messages[messageIndex] as PendingChat)?.choices || []), choice], + } as PendingChat); + } + + /** + * Utility method to add a new Message to a given conversation + * @param conversationId + * @param message + */ + submit(conversationId: string, message: ChatMessage): void { + const conversation = this.#conversations.get(conversationId); + if (conversation === undefined) throw new Error(`conversation with id ${conversationId} does not exist.`); + + this.#conversations.set(conversationId, { + ...conversation, + messages: [...conversation.messages, message], + }); + this.notify(); + } + + dispose(): void { + this.#conversations.clear(); + } + + get(conversationId: string): Conversation | undefined { + return this.#conversations.get(conversationId); + } + + getAll(): Conversation[] { + return Array.from(this.#conversations.values()); + } +} diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index 105fed58f..fe03674df 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -18,18 +18,24 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import content from './ai-test.json'; -import userContent from './ai-user-test.json'; +import { beforeEach, expect, test, vi } from 'vitest'; +import content from './tests/ai-test.json'; +import userContent from './tests/ai-user-test.json'; import type { ApplicationManager } from './managers/applicationManager'; -import type { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; import { StudioApiImpl } from './studio-api-impl'; -import type { PlayGroundManager } from './managers/playground'; -import type { Webview } from '@podman-desktop/api'; +import type { InferenceManager } from './managers/inference/inferenceManager'; +import type { TelemetryLogger, Webview } from '@podman-desktop/api'; +import { EventEmitter } from '@podman-desktop/api'; import { CatalogManager } from './managers/catalogManager'; import type { ModelsManager } from './managers/modelsManager'; import * as fs from 'node:fs'; +import { timeout } from './utils/utils'; +import type { TaskRegistry } from './registries/TaskRegistry'; +import type { LocalRepositoryRegistry } from './registries/LocalRepositoryRegistry'; +import type { Recipe } from '@shared/src/models/IRecipe'; +import type { PlaygroundV2Manager } from './managers/playgroundV2Manager'; +import type { SnippetManager } from './managers/SnippetManager'; vi.mock('./ai.json', () => { return { @@ -46,8 +52,22 @@ vi.mock('node:fs', () => { }; }); -vi.mock('@podman-desktop/api', () => { +const mocks = vi.hoisted(() => ({ + withProgressMock: vi.fn(), + showWarningMessageMock: vi.fn(), + deleteApplicationMock: vi.fn(), +})); + +vi.mock('@podman-desktop/api', async () => { return { + EventEmitter: vi.fn(), + window: { + withProgress: mocks.withProgressMock, + showWarningMessage: mocks.showWarningMessageMock, + }, + ProgressLocation: { + TASK_WIDGET: 'TASK_WIDGET', + }, fs: { createFileSystemWatcher: () => ({ onDidCreate: vi.fn(), @@ -59,70 +79,67 @@ vi.mock('@podman-desktop/api', () => { }); let studioApiImpl: StudioApiImpl; -let catalogManager; +let catalogManager: CatalogManager; beforeEach(async () => { + vi.resetAllMocks(); + const appUserDirectory = '.'; // Creating CatalogManager - catalogManager = new CatalogManager(appUserDirectory, { - postMessage: vi.fn(), - } as unknown as Webview); + catalogManager = new CatalogManager( + { + postMessage: vi.fn().mockResolvedValue(undefined), + } as unknown as Webview, + appUserDirectory, + ); // Creating StudioApiImpl studioApiImpl = new StudioApiImpl( - appUserDirectory, - {} as unknown as ApplicationManager, - {} as unknown as RecipeStatusRegistry, - {} as unknown as PlayGroundManager, + { + deleteApplication: mocks.deleteApplicationMock, + } as unknown as ApplicationManager, catalogManager, {} as unknown as ModelsManager, + {} as TelemetryLogger, + {} as LocalRepositoryRegistry, + {} as unknown as TaskRegistry, + {} as unknown as InferenceManager, + {} as unknown as PlaygroundV2Manager, + {} as unknown as SnippetManager, ); - vi.resetAllMocks(); vi.mock('node:fs'); -}); -describe('invalid user catalog', () => { - beforeEach(async () => { - vi.spyOn(fs.promises, 'readFile').mockResolvedValue('invalid json'); - await catalogManager.loadCatalog(); - }); - - test('expect correct model is returned with valid id', async () => { - const model = await studioApiImpl.getModelById('llama-2-7b-chat.Q5_K_S'); - expect(model).toBeDefined(); - expect(model.name).toEqual('Llama-2-7B-Chat-GGUF'); - expect(model.registry).toEqual('Hugging Face'); - expect(model.url).toEqual( - 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf', - ); - }); - - test('expect error if id does not correspond to any model', async () => { - await expect(() => studioApiImpl.getModelById('unknown')).rejects.toThrowError('No model found having id unknown'); - }); -}); + const listeners: ((value: unknown) => void)[] = []; -test('expect correct model is returned from default catalog with valid id when no user catalog exists', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(false); - await catalogManager.loadCatalog(); - const model = await studioApiImpl.getModelById('llama-2-7b-chat.Q5_K_S'); - expect(model).toBeDefined(); - expect(model.name).toEqual('Llama-2-7B-Chat-GGUF'); - expect(model.registry).toEqual('Hugging Face'); - expect(model.url).toEqual( - 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf', - ); + vi.mocked(EventEmitter).mockReturnValue({ + event: vi.fn().mockImplementation(callback => { + listeners.push(callback); + }), + fire: vi.fn().mockImplementation((content: unknown) => { + listeners.forEach(listener => listener(content)); + }), + } as unknown as EventEmitter); }); -test('expect correct model is returned with valid id when the user catalog is valid', async () => { +test('expect pull application to call the withProgress api method', async () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs.promises, 'readFile').mockResolvedValue(JSON.stringify(userContent)); - await catalogManager.loadCatalog(); - const model = await studioApiImpl.getModelById('model1'); - expect(model).toBeDefined(); - expect(model.name).toEqual('Model 1'); - expect(model.registry).toEqual('Hugging Face'); - expect(model.url).toEqual('https://model1.example.com'); + mocks.withProgressMock.mockResolvedValue(undefined); + + catalogManager.init(); + await vi.waitUntil(() => catalogManager.getRecipes().length > 0); + await studioApiImpl.pullApplication('recipe 1', 'model1'); + expect(mocks.withProgressMock).toHaveBeenCalledOnce(); +}); + +test('requestRemoveApplication should ask confirmation', async () => { + vi.spyOn(catalogManager, 'getRecipeById').mockReturnValue({ + name: 'Recipe 1', + } as unknown as Recipe); + mocks.showWarningMessageMock.mockResolvedValue('Confirm'); + await studioApiImpl.requestRemoveApplication('recipe-id-1', 'model-id-1'); + await timeout(0); + expect(mocks.deleteApplicationMock).toHaveBeenCalled(); }); diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index cc340f9f4..3a7e019ad 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -18,28 +18,134 @@ import type { StudioAPI } from '@shared/src/StudioAPI'; import type { ApplicationManager } from './managers/applicationManager'; -import type { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; -import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; import type { ModelInfo } from '@shared/src/models/IModelInfo'; -import type { PlayGroundManager } from './managers/playground'; import * as podmanDesktopApi from '@podman-desktop/api'; -import type { QueryState } from '@shared/src/models/IPlaygroundQueryState'; import type { CatalogManager } from './managers/catalogManager'; -import type { Catalog } from '@shared/src/models/ICatalog'; -import type { PlaygroundState } from '@shared/src/models/IPlaygroundState'; +import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog'; import type { ModelsManager } from './managers/modelsManager'; +import type { ApplicationState } from '@shared/src/models/IApplicationState'; +import type { Task } from '@shared/src/models/ITask'; +import type { TaskRegistry } from './registries/TaskRegistry'; +import type { LocalRepository } from '@shared/src/models/ILocalRepository'; +import type { LocalRepositoryRegistry } from './registries/LocalRepositoryRegistry'; +import path from 'node:path'; +import type { InferenceServer } from '@shared/src/models/IInference'; +import type { CreationInferenceServerOptions } from '@shared/src/models/InferenceServerConfig'; +import type { InferenceManager } from './managers/inference/inferenceManager'; +import type { Conversation } from '@shared/src/models/IPlaygroundMessage'; +import type { PlaygroundV2Manager } from './managers/playgroundV2Manager'; +import { getFreeRandomPort } from './utils/ports'; +import { withDefaultConfiguration } from './utils/inferenceUtils'; +import type { RequestOptions } from '@shared/src/models/RequestOptions'; +import type { SnippetManager } from './managers/SnippetManager'; +import type { Language } from 'postman-code-generators'; +import type { ModelOptions } from '@shared/src/models/IModelOptions'; +import type { PlaygroundV2 } from '@shared/src/models/IPlaygroundV2'; + +interface PortQuickPickItem extends podmanDesktopApi.QuickPickItem { + port: number; +} export class StudioApiImpl implements StudioAPI { constructor( - private appUserDirectory: string, private applicationManager: ApplicationManager, - private recipeStatusRegistry: RecipeStatusRegistry, - private playgroundManager: PlayGroundManager, private catalogManager: CatalogManager, private modelsManager: ModelsManager, + private telemetry: podmanDesktopApi.TelemetryLogger, + private localRepositories: LocalRepositoryRegistry, + private taskRegistry: TaskRegistry, + private inferenceManager: InferenceManager, + private playgroundV2: PlaygroundV2Manager, + private snippetManager: SnippetManager, ) {} + async requestDeleteConversation(conversationId: string): Promise { + // Do not wait on the promise as the api would probably timeout before the user answer. + podmanDesktopApi.window + .showWarningMessage(`Are you sure you want to delete this playground ?`, 'Confirm', 'Cancel') + .then((result: string) => { + if (result === 'Confirm') { + this.playgroundV2.deletePlayground(conversationId); + } + }) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + } + + async requestCreatePlayground(name: string, model: ModelInfo): Promise { + try { + return this.playgroundV2.requestCreatePlayground(name, model); + } catch (err: unknown) { + console.error('Something went wrong while trying to create playground environment', err); + throw err; + } + } + + async getPlaygroundsV2(): Promise { + return this.playgroundV2.getPlaygrounds(); + } + + submitPlaygroundMessage( + containerId: string, + userInput: string, + systemPrompt: string, + options?: ModelOptions, + ): Promise { + return this.playgroundV2.submit(containerId, userInput, systemPrompt, options); + } + + async getPlaygroundConversations(): Promise { + return this.playgroundV2.getConversations(); + } + + async getSnippetLanguages(): Promise { + return this.snippetManager.getLanguageList(); + } + + createSnippet(options: RequestOptions, language: string, variant: string): Promise { + return this.snippetManager.generate(options, language, variant); + } + + async getInferenceServers(): Promise { + return this.inferenceManager.getServers(); + } + + async requestDeleteInferenceServer(containerId: string): Promise { + // Do not wait on the promise as the api would probably timeout before the user answer. + podmanDesktopApi.window + .showWarningMessage(`Are you sure you want to delete this service ?`, 'Confirm', 'Cancel') + .then((result: string) => { + if (result === 'Confirm') { + this.inferenceManager.deleteInferenceServer(containerId).catch((err: unknown) => { + console.error('Something went wrong while trying to delete the inference server', err); + }); + } + }) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + } + + async requestCreateInferenceServer(options: CreationInferenceServerOptions): Promise { + try { + const config = await withDefaultConfiguration(options); + return this.inferenceManager.requestCreateInferenceServer(config); + } catch (err: unknown) { + console.error('Something went wrong while trying to start inference server', err); + throw err; + } + } + + startInferenceServer(containerId: string): Promise { + return this.inferenceManager.startInferenceServer(containerId); + } + + stopInferenceServer(containerId: string): Promise { + return this.inferenceManager.stopInferenceServer(containerId); + } + async ping(): Promise { return 'pong'; } @@ -48,63 +154,228 @@ export class StudioApiImpl implements StudioAPI { return await podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.parse(url)); } - async getPullingStatus(recipeId: string): Promise { - return this.recipeStatusRegistry.getStatus(recipeId); + async openFile(file: string): Promise { + return await podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.file(file)); } - async getPullingStatuses(): Promise> { - return this.recipeStatusRegistry.getStatuses(); + async pullApplication(recipeId: string, modelId: string): Promise { + const recipe = this.catalogManager.getRecipes().find(recipe => recipe.id === recipeId); + if (!recipe) throw new Error(`recipe with if ${recipeId} not found`); + + const model = this.catalogManager.getModelById(modelId); + + // Do not wait for the pull application, run it separately + podmanDesktopApi.window + .withProgress({ location: podmanDesktopApi.ProgressLocation.TASK_WIDGET, title: `Pulling ${recipe.name}.` }, () => + this.applicationManager.pullApplication(recipe, model), + ) + .catch((err: unknown) => { + console.error('Something went wrong while trying to start application', err); + podmanDesktopApi.window + .showErrorMessage(`Error starting the application "${recipe.name}": ${String(err)}`) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + }); } - async getModelById(modelId: string): Promise { - const model = this.catalogManager.getModels().find(m => modelId === m.id); - if (!model) { - throw new Error(`No model found having id ${modelId}`); - } - return model; + async getModelsInfo(): Promise { + return this.modelsManager.getModelsInfo(); } - async pullApplication(recipeId: string): Promise { - const recipe = this.catalogManager.getRecipes().find(recipe => recipe.id === recipeId); - if (!recipe) throw new Error('Not found'); + async getCatalog(): Promise { + return this.catalogManager.getCatalog(); + } - // the user should have selected one model, we use the first one for the moment - const modelId = recipe.models[0]; - const model = await this.getModelById(modelId); + async requestRemoveLocalModel(modelId: string): Promise { + const modelInfo = this.modelsManager.getLocalModelInfo(modelId); - // Do not wait for the pull application, run it separately - this.applicationManager.pullApplication(recipe, model).catch((error: unknown) => console.warn(error)); + // Do not wait on the promise as the api would probably timeout before the user answer. + podmanDesktopApi.window + .showWarningMessage( + `Are you sure you want to delete ${modelId} ? The following files will be removed from disk "${modelInfo.file}".`, + 'Confirm', + 'Cancel', + ) + .then((result: string) => { + if (result === 'Confirm') { + this.modelsManager.deleteLocalModel(modelId).catch((err: unknown) => { + console.error('Something went wrong while deleting the models', err); + // Lets reloads the models (could fix the issue) + this.modelsManager.loadLocalModels().catch((err: unknown) => { + console.error('Cannot reload the models', err); + }); + }); + } + }) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + } - return Promise.resolve(undefined); + async getModelsDirectory(): Promise { + return this.modelsManager.getModelsDirectory(); } - async getLocalModels(): Promise { - return this.modelsManager.getModelsInfo(); + navigateToContainer(containerId: string): Promise { + return podmanDesktopApi.navigation.navigateToContainer(containerId); } - async startPlayground(modelId: string): Promise { - const modelPath = this.modelsManager.getLocalModelPath(modelId); - await this.playgroundManager.startPlayground(modelId, modelPath); + async navigateToPod(podId: string): Promise { + const pods = await podmanDesktopApi.containerEngine.listPods(); + const pod = pods.find(pod => pod.Id === podId); + if (pod === undefined) throw new Error(`Pod with id ${podId} not found.`); + return podmanDesktopApi.navigation.navigateToPod(pod.kind, pod.Name, pod.engineId); } - async stopPlayground(modelId: string): Promise { - await this.playgroundManager.stopPlayground(modelId); + async getApplicationsState(): Promise { + return this.applicationManager.getApplicationsState(); } - askPlayground(modelId: string, prompt: string): Promise { - const localModelInfo = this.modelsManager.getLocalModelInfo(modelId); - return this.playgroundManager.askPlayground(localModelInfo, prompt); + async requestRemoveApplication(recipeId: string, modelId: string): Promise { + const recipe = this.catalogManager.getRecipeById(recipeId); + // Do not wait on the promise as the api would probably timeout before the user answer. + podmanDesktopApi.window + .showWarningMessage( + `Stop the AI App "${recipe.name}"? This will delete the containers running the application and model.`, + 'Confirm', + 'Cancel', + ) + .then((result: string) => { + if (result === 'Confirm') { + this.applicationManager.deleteApplication(recipeId, modelId).catch((err: unknown) => { + console.error(`error deleting AI App's pod: ${String(err)}`); + podmanDesktopApi.window + .showErrorMessage( + `Error deleting the AI App "${recipe.name}". You can try to stop and delete the AI App's pod manually.`, + ) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + }); + } + }) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); } - async getPlaygroundQueriesState(): Promise { - return this.playgroundManager.getQueriesState(); + async requestRestartApplication(recipeId: string, modelId: string): Promise { + const recipe = this.catalogManager.getRecipeById(recipeId); + // Do not wait on the promise as the api would probably timeout before the user answer. + podmanDesktopApi.window + .showWarningMessage( + `Restart the AI App "${recipe.name}"? This will delete the containers running the application and model, rebuild the images with the current sources, and restart the containers.`, + 'Confirm', + 'Cancel', + ) + .then((result: string) => { + if (result === 'Confirm') { + this.applicationManager.restartApplication(recipeId, modelId).catch((err: unknown) => { + console.error(`error restarting AI App: ${String(err)}`); + podmanDesktopApi.window + .showErrorMessage(`Error restarting the AI App "${recipe.name}"`) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + }); + } + }) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); } - async getPlaygroundsState(): Promise { - return this.playgroundManager.getPlaygroundsState(); + async requestOpenApplication(recipeId: string, modelId: string): Promise { + const recipe = this.catalogManager.getRecipeById(recipeId); + this.applicationManager + .getApplicationPorts(recipeId, modelId) + .then((ports: number[]) => { + if (ports.length === 0) { + podmanDesktopApi.window + .showErrorMessage(`AI App ${recipe.name} has no application ports to open`) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + } else if (ports.length === 1) { + const uri = `http://localhost:${ports[0]}`; + podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.parse(uri)).catch((err: unknown) => { + console.error(`Something went wrong while opening ${uri}`, err); + }); + } else { + podmanDesktopApi.window + .showQuickPick( + ports.map(p => { + const item: PortQuickPickItem = { port: p, label: `${p}`, description: `Port ${p}` }; + return item; + }), + { placeHolder: 'Select the port to open' }, + ) + .then((selectedPort: PortQuickPickItem) => { + const uri = `http://localhost:${selectedPort.port}`; + podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.parse(uri)).catch((err: unknown) => { + console.error(`Something went wrong while opening ${uri}`, err); + }); + }) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + } + }) + .catch((err: unknown) => { + console.error(`error opening AI App: ${String(err)}`); + podmanDesktopApi.window.showErrorMessage(`Error opening the AI App "${recipe.name}"`).catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + }); } - async getCatalog(): Promise { - return this.catalogManager.getCatalog(); + async telemetryLogUsage( + eventName: string, + data?: Record, + ): Promise { + this.telemetry.logUsage(eventName, data); + } + + async telemetryLogError( + eventName: string, + data?: Record, + ): Promise { + this.telemetry.logError(eventName, data); + } + + async getLocalRepositories(): Promise { + return this.localRepositories.getLocalRepositories(); + } + + async getTasks(): Promise { + return this.taskRegistry.getTasks(); + } + + async openVSCode(directory: string): Promise { + if (!path.isAbsolute(directory)) { + throw new Error('Do not support relative directory.'); + } + + const unixPath: string = path.normalize(directory).replace(/[\\/]+/g, '/'); + + podmanDesktopApi.env + .openExternal(podmanDesktopApi.Uri.parse(unixPath).with({ scheme: 'vscode', authority: 'file' })) + .catch((err: unknown) => { + console.error('Something went wrong while trying to open VSCode', err); + }); + } + + async downloadModel(modelId: string): Promise { + const modelInfo: ModelInfo = this.modelsManager.getModelInfo(modelId); + + // Do not wait for the download task as it is too long. + this.modelsManager.requestDownloadModel(modelInfo).catch((err: unknown) => { + console.error(`Something went wrong while trying to download the model ${modelId}`, err); + }); + } + + getHostFreePort(): Promise { + return getFreeRandomPort('0.0.0.0'); } } diff --git a/packages/backend/src/studio.spec.ts b/packages/backend/src/studio.spec.ts index f15bec5d8..7a6e17437 100644 --- a/packages/backend/src/studio.spec.ts +++ b/packages/backend/src/studio.spec.ts @@ -20,7 +20,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { Studio } from './studio'; -import type { ExtensionContext } from '@podman-desktop/api'; +import { type ExtensionContext, EventEmitter } from '@podman-desktop/api'; import * as fs from 'node:fs'; @@ -32,8 +32,18 @@ const mockedExtensionContext = { const studio = new Studio(mockedExtensionContext); +const mocks = vi.hoisted(() => ({ + listContainers: vi.fn(), + getContainerConnections: vi.fn(), + postMessage: vi.fn(), +})); + vi.mock('@podman-desktop/api', async () => { return { + fs: { + createFileSystemWatcher: vi.fn(), + }, + EventEmitter: vi.fn(), Uri: class { static joinPath = () => ({ fsPath: '.' }); }, @@ -42,10 +52,25 @@ vi.mock('@podman-desktop/api', async () => { webview: { html: '', onDidReceiveMessage: vi.fn(), - postMessage: vi.fn(), + postMessage: mocks.postMessage, }, + onDidChangeViewState: vi.fn(), + }), + }, + env: { + createTelemetryLogger: () => ({ + logUsage: vi.fn(), }), }, + containerEngine: { + onEvent: vi.fn(), + listContainers: mocks.listContainers, + }, + provider: { + onDidRegisterContainerConnection: vi.fn(), + onDidUpdateContainerConnection: vi.fn(), + getContainerConnections: mocks.getContainerConnections, + }, }; }); @@ -56,6 +81,13 @@ const consoleLogMock = vi.fn(); beforeEach(() => { vi.clearAllMocks(); console.log = consoleLogMock; + + vi.mocked(EventEmitter).mockReturnValue({ + event: vi.fn(), + fire: vi.fn(), + } as unknown as EventEmitter); + + mocks.postMessage.mockResolvedValue(undefined); }); afterEach(() => { @@ -63,6 +95,8 @@ afterEach(() => { }); test('check activate ', async () => { + mocks.listContainers.mockReturnValue([]); + mocks.getContainerConnections.mockReturnValue([]); vi.spyOn(fs.promises, 'readFile').mockImplementation(() => { return Promise.resolve(''); }); diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index 550d9deff..5840b00e7 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -16,20 +16,30 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { ExtensionContext, WebviewOptions, WebviewPanel } from '@podman-desktop/api'; -import { Uri, window } from '@podman-desktop/api'; +import { Uri, window, env } from '@podman-desktop/api'; +import type { + ExtensionContext, + TelemetryLogger, + WebviewOptions, + WebviewPanel, + WebviewPanelOnDidChangeViewStateEvent, +} from '@podman-desktop/api'; import { RpcExtension } from '@shared/src/messages/MessageProxy'; import { StudioApiImpl } from './studio-api-impl'; import { ApplicationManager } from './managers/applicationManager'; import { GitManager } from './managers/gitManager'; -import { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; import { TaskRegistry } from './registries/TaskRegistry'; -import { PlayGroundManager } from './managers/playground'; import { CatalogManager } from './managers/catalogManager'; import { ModelsManager } from './managers/modelsManager'; import path from 'node:path'; import os from 'os'; import fs from 'node:fs'; +import { ContainerRegistry } from './registries/ContainerRegistry'; +import { PodmanConnection } from './managers/podmanConnection'; +import { LocalRepositoryRegistry } from './registries/LocalRepositoryRegistry'; +import { InferenceManager } from './managers/inference/inferenceManager'; +import { PlaygroundV2Manager } from './managers/playgroundV2Manager'; +import { SnippetManager } from './managers/SnippetManager'; // TODO: Need to be configured export const AI_STUDIO_FOLDER = path.join('podman-desktop', 'ai-studio'); @@ -41,9 +51,11 @@ export class Studio { rpcExtension: RpcExtension; studioApi: StudioApiImpl; - playgroundManager: PlayGroundManager; catalogManager: CatalogManager; modelsManager: ModelsManager; + telemetry: TelemetryLogger; + + #inferenceManager: InferenceManager; constructor(readonly extensionContext: ExtensionContext) { this.#extensionContext = extensionContext; @@ -52,10 +64,13 @@ export class Studio { public async activate(): Promise { console.log('starting studio extension'); + this.telemetry = env.createTelemetryLogger(); + this.telemetry.logUsage('start'); + const extensionUri = this.#extensionContext.extensionUri; // register webview - this.#panel = window.createWebviewPanel('studio', 'Studio extension', this.getWebviewOptions(extensionUri)); + this.#panel = window.createWebviewPanel('studio', 'AI Studio', this.getWebviewOptions(extensionUri)); this.#extensionContext.subscriptions.push(this.#panel); // update html @@ -94,43 +109,93 @@ export class Studio { this.#panel.webview.html = indexHtml; + // Creating container registry + const containerRegistry = new ContainerRegistry(); + this.#extensionContext.subscriptions.push(containerRegistry.init()); + // Let's create the api that the front will be able to call const appUserDirectory = path.join(os.homedir(), AI_STUDIO_FOLDER); this.rpcExtension = new RpcExtension(this.#panel.webview); const gitManager = new GitManager(); - const taskRegistry = new TaskRegistry(); - const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry, this.#panel.webview); - this.playgroundManager = new PlayGroundManager(this.#panel.webview); + + const podmanConnection = new PodmanConnection(); + const taskRegistry = new TaskRegistry(this.#panel.webview); + // Create catalog manager, responsible for loading the catalog files and watching for changes - this.catalogManager = new CatalogManager(appUserDirectory, this.#panel.webview); - this.modelsManager = new ModelsManager(appUserDirectory, this.#panel.webview, this.catalogManager); + this.catalogManager = new CatalogManager(this.#panel.webview, appUserDirectory); + this.modelsManager = new ModelsManager( + appUserDirectory, + this.#panel.webview, + this.catalogManager, + this.telemetry, + taskRegistry, + ); + const localRepositoryRegistry = new LocalRepositoryRegistry(this.#panel.webview); const applicationManager = new ApplicationManager( appUserDirectory, gitManager, - recipeStatusRegistry, + taskRegistry, + this.#panel.webview, + podmanConnection, + this.catalogManager, this.modelsManager, + this.telemetry, + localRepositoryRegistry, ); + this.#inferenceManager = new InferenceManager( + this.#panel.webview, + containerRegistry, + podmanConnection, + this.modelsManager, + this.telemetry, + taskRegistry, + ); + + this.#panel.onDidChangeViewState((e: WebviewPanelOnDidChangeViewStateEvent) => { + // Lazily init inference manager + if (!this.#inferenceManager.isInitialize()) { + this.#inferenceManager.init(); + this.#extensionContext.subscriptions.push(this.#inferenceManager); + } + + this.telemetry.logUsage(e.webviewPanel.visible ? 'opened' : 'closed'); + }); + + const playgroundV2 = new PlaygroundV2Manager(this.#panel.webview, this.#inferenceManager, taskRegistry); + + const snippetManager = new SnippetManager(this.#panel.webview); + snippetManager.init(); + // Creating StudioApiImpl this.studioApi = new StudioApiImpl( - appUserDirectory, applicationManager, - recipeStatusRegistry, - this.playgroundManager, this.catalogManager, this.modelsManager, + this.telemetry, + localRepositoryRegistry, + taskRegistry, + this.#inferenceManager, + playgroundV2, + snippetManager, ); - await this.catalogManager.loadCatalog(); + this.catalogManager.init(); await this.modelsManager.loadLocalModels(); + podmanConnection.init(); + applicationManager.adoptRunningApplications(); // Register the instance this.rpcExtension.registerInstance(StudioApiImpl, this.studioApi); + this.#extensionContext.subscriptions.push(this.catalogManager); + this.#extensionContext.subscriptions.push(this.modelsManager); + this.#extensionContext.subscriptions.push(podmanConnection); } public async deactivate(): Promise { console.log('stopping studio extension'); + this.telemetry.logUsage('stop'); } getWebviewOptions(extensionUri: Uri): WebviewOptions { diff --git a/packages/backend/src/templates/java-okhttp.mustache b/packages/backend/src/templates/java-okhttp.mustache new file mode 100644 index 000000000..62636d848 --- /dev/null +++ b/packages/backend/src/templates/java-okhttp.mustache @@ -0,0 +1,44 @@ +pom.xml +======= + + com.squareup.okhttp + okhttp + 2.7.5 + + +AiService.java +============== +package io.podman.desktop.java.okhttp; + +import com.squareup.okhttp.MediaType; +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.RequestBody; +import com.squareup.okhttp.Response; + +OkHttpClient client = new OkHttpClient(); +MediaType mediaType = MediaType.parse("application/json"); +String json = +""" + { + "messages": [ + { + "content": "You are a helpful assistant.", + "role": "system" + }, + { + "content": "What is the capital of France?", + "role": "user" + } + ] + } +"""; +RequestBody body = RequestBody.create(mediaType, json); +Request request = new Request.Builder() +.url("{{{ endpoint }}}") +.method("POST", body) +.addHeader("Content-Type", "application/json") +.build(); +Response response = client.newCall(request).execute(); + +====== diff --git a/packages/backend/src/templates/quarkus-langchain4j.mustache b/packages/backend/src/templates/quarkus-langchain4j.mustache new file mode 100644 index 000000000..c9fe2ce03 --- /dev/null +++ b/packages/backend/src/templates/quarkus-langchain4j.mustache @@ -0,0 +1,36 @@ +application.properties +====================== +quarkus.langchain4j.openai.base-url={{{ baseUrl }}} +quarkus.langchain4j.openai.api-key=sk-dummy + +pom.xml +======= + + io.quarkiverse.langchain4j + quarkus-langchain4j-core + {{{ version }}} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-openai + {{{ version }}} + + +AiService.java +============== +package io.podman.desktop.quarkus.langchain4j; + +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; + +@RegisterAiService +public interface AiService { + +@UserMessage("{question}") +String request(String question); +} + +====== +Inject AIService into REST resource or other CDI resource and use the request method to call the LLM model. That's it + + diff --git a/packages/backend/src/ai-test.json b/packages/backend/src/tests/ai-test.json similarity index 88% rename from packages/backend/src/ai-test.json rename to packages/backend/src/tests/ai-test.json index 2ed682e49..f998a9080 100644 --- a/packages/backend/src/ai-test.json +++ b/packages/backend/src/tests/ai-test.json @@ -10,7 +10,7 @@ "natural-language-processing" ], "config": "chatbot/ai-studio.yaml", - "readme": "# Locallm\n\nThis repo contains artifacts that can be used to build and run LLM (Large Language Model) services locally on your Mac using podman. These containerized LLM services can be used to help developers quickly prototype new LLM based applications, without the need for relying on any other externally hosted services. Since they are already containerized, it also helps developers move from their prototype to production quicker. \n\n## Current Locallm Services: \n\n* [Chatbot](#chatbot)\n* [Text Summarization](#text-summarization)\n* [Fine-tuning](#fine-tuning)\n\n### Chatbot\n\nA simple chatbot using the gradio UI. Learn how to build and run this model service here: [Chatbot](/chatbot/).\n\n### Text Summarization\n\nAn LLM app that can summarize arbitrarily long text inputs. Learn how to build and run this model service here: [Text Summarization](/summarizer/).\n\n### Fine Tuning \n\nThis application allows a user to select a model and a data set they'd like to fine-tune that model on. Once the application finishes, it outputs a new fine-tuned model for the user to apply to other LLM services. Learn how to build and run this model training job here: [Fine-tuning](/finetune/).\n\n## Architecture\n![](https://raw.githubusercontent.com/MichaelClifford/locallm/main/assets/arch.jpg)\n\nThe diagram above indicates the general architecture for each of the individual model services contained in this repo. The core code available here is the \"LLM Task Service\" and the \"API Server\", bundled together under `model_services`. With an appropriately chosen model downloaded onto your host,`model_services/builds` contains the Containerfiles required to build an ARM or an x86 (with CUDA) image depending on your need. These model services are intended to be light-weight and run with smaller hardware footprints (given the Locallm name), but they can be run on any hardware that supports containers and scaled up if needed.\n\nWe also provide demo \"AI Applications\" under `ai_applications` for each model service to provide an example of how a developers could interact with the model service for their own needs. ", + "readme": "# Locallm\n\nThis repo contains artifacts that can be used to build and run LLM (Large Language Model) services locally on your Mac using podman. These containerized LLM services can be used to help developers quickly prototype new LLM based applications, without the need for relying on any other externally hosted services. Since they are already containerized, it also helps developers move from their prototype to production quicker. \n\n## Current Locallm Services: \n\n* [Chatbot](#chatbot)\n* [Text Summarization](#text-summarization)\n* [Fine-tuning](#fine-tuning)\n\n### Chatbot\n\nA simple chatbot using the gradio UI. Learn how to build and run this model service here: [Chatbot](/chatbot/).\n\n### Text Summarization\n\nAn LLM app that can summarize arbitrarily long text inputs. Learn how to build and run this model service here: [Text Summarization](/summarizer/).\n\n### Fine Tuning \n\nThis application allows a user to select a model and a data set they'd like to fine-tune that model on. Once the application finishes, it outputs a new fine-tuned model for the user to apply to other LLM services. Learn how to build and run this model training job here: [Fine-tuning](/finetune/).\n\n## Architecture\n![](https://raw.githubusercontent.com/MichaelClifford/locallm/main/assets/arch.jpg)\n\nThe diagram above indicates the general architecture for each of the individual model services contained in this repo. The core code available here is the \"LLM Task Service\" and the \"API Server\", bundled together under `model_services`. With an appropriately chosen model downloaded onto your host, `model_services/builds` contains the Containerfiles required to build an ARM or an x86 (with CUDA) image depending on your need. These model services are intended to be light-weight and run with smaller hardware footprints (given the Locallm name), but they can be run on any hardware that supports containers and scaled up if needed.\n\nWe also provide demo \"AI Applications\" under `ai_applications` for each model service to provide an example of how a developers could interact with the model service for their own needs. ", "models": [ "llama-2-7b-chat.Q5_K_S", "albedobase-xl-1.3", @@ -25,7 +25,6 @@ "description": "Llama 2 is a family of state-of-the-art open-access large language models released by Meta today, and we’re excited to fully support the launch with comprehensive integration in Hugging Face. Llama 2 is being released with a very permissive community license and is available for commercial use. The code, pretrained models, and fine-tuned models are all being released today πŸ”₯", "hw": "CPU", "registry": "Hugging Face", - "popularity": 3, "license": "?", "url": "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf" }, @@ -35,7 +34,6 @@ "description": "Stable Diffusion XL has 6.6 billion parameters, which is about 6.6 times more than the SD v1.5 version. I believe that this is not just a number, but a number that can lead to a significant improvement in performance. It has been a while since we realized that the overall performance of SD v1.5 has improved beyond imagination thanks to the explosive contributions of our community. Therefore, I am working on completing this AlbedoBase XL model in order to optimally reproduce the performance improvement that occurred in v1.5 in this XL version as well. My goal is to directly test the performance of all Checkpoints and LoRAs that are publicly uploaded to Civitai, and merge only the resources that are judged to be optimal after passing through several filters. This will surpass the performance of image-generating AI of companies such as Midjourney. As of now, AlbedoBase XL v0.4 has merged exactly 55 selected checkpoints and 138 LoRAs.", "hw": "CPU", "registry": "Civital", - "popularity": 3, "license": "openrail++", "url": "" }, @@ -45,7 +43,6 @@ "description": "SDXL Turbo achieves state-of-the-art performance with a new distillation technology, enabling single-step image generation with unprecedented quality, reducing the required step count from 50 to just one.", "hw": "CPU", "registry": "Hugging Face", - "popularity": 3, "license": "sai-c-community", "url": "" } diff --git a/packages/backend/src/ai-user-test.json b/packages/backend/src/tests/ai-user-test.json similarity index 95% rename from packages/backend/src/ai-user-test.json rename to packages/backend/src/tests/ai-user-test.json index cc44b1aab..20d8f31f0 100644 --- a/packages/backend/src/ai-user-test.json +++ b/packages/backend/src/tests/ai-user-test.json @@ -24,7 +24,6 @@ "description": "Readme for model 1", "hw": "CPU", "registry": "Hugging Face", - "popularity": 3, "license": "?", "url": "https://model1.example.com" }, @@ -34,7 +33,6 @@ "description": "Readme for model 2", "hw": "CPU", "registry": "Civital", - "popularity": 3, "license": "?", "url": "" } diff --git a/packages/backend/src/utils/JsonWatcher.spec.ts b/packages/backend/src/utils/JsonWatcher.spec.ts new file mode 100644 index 000000000..bca24e88a --- /dev/null +++ b/packages/backend/src/utils/JsonWatcher.spec.ts @@ -0,0 +1,146 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { promises, existsSync } from 'node:fs'; +import type { FileSystemWatcher } from '@podman-desktop/api'; +import { EventEmitter, fs } from '@podman-desktop/api'; +import { JsonWatcher } from './JsonWatcher'; + +vi.mock('@podman-desktop/api', () => { + return { + EventEmitter: vi.fn(), + fs: { + createFileSystemWatcher: () => ({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + onDidChange: vi.fn(), + }), + }, + }; +}); + +vi.mock('node:fs', () => { + return { + existsSync: vi.fn(), + promises: { + readFile: vi.fn(), + }, + }; +}); + +beforeEach(() => { + vi.resetAllMocks(); + // Mock event emitters + const listeners: ((value: unknown) => void)[] = []; + vi.mocked(EventEmitter).mockReturnValue({ + event: vi.fn().mockImplementation(callback => { + listeners.push(callback); + }), + fire: vi.fn().mockImplementation((content: unknown) => { + listeners.forEach(listener => listener(content)); + }), + } as unknown as EventEmitter); +}); + +test('should provide default value', async () => { + vi.mocked(existsSync).mockReturnValue(false); + const watcher = new JsonWatcher('dummyPath', 'dummyDefaultvalue'); + const listener = vi.fn(); + watcher.onContentUpdated(listener); + + watcher.init(); + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledWith('dummyDefaultvalue'); + }); + expect(existsSync).toHaveBeenCalledWith('dummyPath'); + expect(promises.readFile).not.toHaveBeenCalled(); +}); + +test('should read file content', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.spyOn(promises, 'readFile').mockResolvedValue('["hello"]'); + const watcher = new JsonWatcher('dummyPath', []); + const listener = vi.fn(); + watcher.onContentUpdated(listener); + + watcher.init(); + + await vi.waitFor(() => { + expect(listener).toHaveBeenCalledWith(['hello']); + }); + expect(promises.readFile).toHaveBeenCalledWith('dummyPath', 'utf-8'); +}); + +describe('file system watcher events should fire onContentUpdated', () => { + let onDidCreateListener: () => void; + let onDidDeleteListener: () => void; + let onDidChangeListener: () => void; + beforeEach(() => { + vi.spyOn(fs, 'createFileSystemWatcher').mockReturnValue({ + onDidCreate: vi.fn().mockImplementation(listener => (onDidCreateListener = listener)), + onDidDelete: vi.fn().mockImplementation(listener => (onDidDeleteListener = listener)), + onDidChange: vi.fn().mockImplementation(listener => (onDidChangeListener = listener)), + } as unknown as FileSystemWatcher); + }); + + test('onDidCreate', async () => { + vi.mocked(existsSync).mockReturnValue(false); + const watcher = new JsonWatcher('dummyPath', 'dummyDefaultValue'); + const listener = vi.fn(); + watcher.onContentUpdated(listener); + watcher.init(); + + expect(onDidCreateListener).toBeDefined(); + onDidCreateListener(); + + await vi.waitFor(() => { + expect(listener).toHaveBeenNthCalledWith(2, 'dummyDefaultValue'); + }); + }); + + test('onDidDeleteListener', async () => { + vi.mocked(existsSync).mockReturnValue(false); + const watcher = new JsonWatcher('dummyPath', 'dummyDefaultValue'); + const listener = vi.fn(); + watcher.onContentUpdated(listener); + watcher.init(); + + expect(onDidDeleteListener).toBeDefined(); + onDidDeleteListener(); + + await vi.waitFor(() => { + expect(listener).toHaveBeenNthCalledWith(2, 'dummyDefaultValue'); + }); + }); + + test('onDidChangeListener', async () => { + vi.mocked(existsSync).mockReturnValue(false); + const watcher = new JsonWatcher('dummyPath', 'dummyDefaultValue'); + const listener = vi.fn(); + watcher.onContentUpdated(listener); + watcher.init(); + + expect(onDidChangeListener).toBeDefined(); + onDidChangeListener(); + + await vi.waitFor(() => { + expect(listener).toHaveBeenNthCalledWith(2, 'dummyDefaultValue'); + }); + }); +}); diff --git a/packages/backend/src/utils/JsonWatcher.ts b/packages/backend/src/utils/JsonWatcher.ts new file mode 100644 index 000000000..95dacbcf8 --- /dev/null +++ b/packages/backend/src/utils/JsonWatcher.ts @@ -0,0 +1,80 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { type Disposable, type FileSystemWatcher, fs, EventEmitter, type Event } from '@podman-desktop/api'; +import { promises, existsSync } from 'node:fs'; + +export class JsonWatcher implements Disposable { + #fileSystemWatcher: FileSystemWatcher | undefined; + + private readonly _onEvent = new EventEmitter(); + readonly onContentUpdated: Event = this._onEvent.event; + + constructor( + private path: string, + private defaultValue: T, + ) {} + + init(): void { + try { + this.#fileSystemWatcher = fs.createFileSystemWatcher(this.path); + // Setup listeners + this.#fileSystemWatcher.onDidChange(this.onDidChange.bind(this)); + this.#fileSystemWatcher.onDidDelete(this.onDidDelete.bind(this)); + this.#fileSystemWatcher.onDidCreate(this.onDidCreate.bind(this)); + } catch (err: unknown) { + console.error(`unable to watch file ${this.path}, changes wont be detected.`, err); + } + this.requestUpdate(); + } + + private onDidCreate(): void { + this.requestUpdate(); + } + + private onDidDelete(): void { + this.requestUpdate(); + } + + private onDidChange(): void { + this.requestUpdate(); + } + + private requestUpdate(): void { + this.updateContent().catch((err: unknown) => { + console.error('Something went wrong in update content', err); + }); + } + + private async updateContent(): Promise { + if (!existsSync(this.path)) { + this._onEvent.fire(this.defaultValue); + return; + } + + try { + const data = await promises.readFile(this.path, 'utf-8'); + this._onEvent.fire(JSON.parse(data)); + } catch (err: unknown) { + console.error('Something went wrong JsonWatcher', err); + } + } + + dispose(): void { + this.#fileSystemWatcher?.dispose(); + } +} diff --git a/packages/backend/src/utils/Publisher.spec.ts b/packages/backend/src/utils/Publisher.spec.ts new file mode 100644 index 000000000..d5beb8688 --- /dev/null +++ b/packages/backend/src/utils/Publisher.spec.ts @@ -0,0 +1,42 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { expect, test, vi } from 'vitest'; +import { Publisher } from './Publisher'; +import type { Webview } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; + +test('ensure publisher properly use getter', async () => { + const postMessageMock = vi.fn().mockResolvedValue(undefined); + const getterMock = vi.fn().mockReturnValue('dummyValue'); + const publisher = new Publisher( + { + postMessage: postMessageMock, + } as unknown as Webview, + Messages.MSG_TASKS_UPDATE, + getterMock, + ); + publisher.notify(); + + await vi.waitFor(() => { + expect(postMessageMock).toHaveBeenCalledWith({ + id: Messages.MSG_TASKS_UPDATE, + body: 'dummyValue', + }); + }); + expect(getterMock).toHaveBeenCalled(); +}); diff --git a/packages/backend/src/utils/Publisher.ts b/packages/backend/src/utils/Publisher.ts new file mode 100644 index 000000000..e2abd6c5e --- /dev/null +++ b/packages/backend/src/utils/Publisher.ts @@ -0,0 +1,38 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { Webview } from '@podman-desktop/api'; +import type { Messages } from '@shared/Messages'; + +export class Publisher { + constructor( + private webview: Webview, + private channel: Messages, + private getter: () => T, + ) {} + + notify(): void { + this.webview + .postMessage({ + id: this.channel, + body: this.getter(), + }) + .catch((err: unknown) => { + console.error(`Something went wrong while emitting ${this.channel}: ${String(err)}`); + }); + } +} diff --git a/packages/backend/src/utils/WSLUploader.spec.ts b/packages/backend/src/utils/WSLUploader.spec.ts new file mode 100644 index 000000000..36c93ca16 --- /dev/null +++ b/packages/backend/src/utils/WSLUploader.spec.ts @@ -0,0 +1,129 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, describe, vi } from 'vitest'; +import { WSLUploader } from './WSLUploader'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import * as utils from './podman'; +import { beforeEach } from 'node:test'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +const mocks = vi.hoisted(() => { + return { + execMock: vi.fn(), + }; +}); + +vi.mock('@podman-desktop/api', () => ({ + env: { + isWindows: false, + }, + process: { + exec: mocks.execMock, + }, +})); + +const wslUploader = new WSLUploader(); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('canUpload', () => { + test('should return false if system is not windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = false; + const result = wslUploader.canUpload(); + expect(result).toBeFalsy(); + }); + test('should return true if system is windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = true; + const result = wslUploader.canUpload(); + expect(result).toBeTruthy(); + }); +}); + +describe('upload', () => { + vi.spyOn(utils, 'getPodmanCli').mockReturnValue('podman'); + vi.spyOn(utils, 'getFirstRunningPodmanConnection').mockResolvedValue({ + connection: { + name: 'test', + status: vi.fn(), + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }); + test('throw if localpath is not defined', async () => { + await expect( + wslUploader.upload({ + file: undefined, + } as unknown as ModelInfo), + ).rejects.toThrowError('model is not available locally.'); + }); + test('copy model if not exists on podman machine', async () => { + mocks.execMock.mockRejectedValueOnce('error'); + vi.spyOn(utils, 'getFirstRunningMachineName').mockReturnValue('machine2'); + await wslUploader.upload({ + id: 'dummyId', + file: { path: 'C:\\Users\\podman\\folder', file: 'dummy.guff' }, + } as unknown as ModelInfo); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'stat', + '/home/user/ai-studio/models/dummy.guff', + ]); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'mkdir', + '-p', + '/home/user/ai-studio/models/', + ]); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'cp', + '/mnt/c/Users/podman/folder/dummy.guff', + '/home/user/ai-studio/models/dummy.guff', + ]); + mocks.execMock.mockClear(); + }); + test('do not copy model if it exists on podman machine', async () => { + mocks.execMock.mockResolvedValue(''); + vi.spyOn(utils, 'getFirstRunningMachineName').mockReturnValue('machine2'); + await wslUploader.upload({ + id: 'dummyId', + file: { path: 'C:\\Users\\podman\\folder', file: 'dummy.guff' }, + } as unknown as ModelInfo); + expect(mocks.execMock).toBeCalledWith('podman', [ + 'machine', + 'ssh', + 'machine2', + 'stat', + '/home/user/ai-studio/models/dummy.guff', + ]); + expect(mocks.execMock).toBeCalledTimes(1); + mocks.execMock.mockClear(); + }); +}); diff --git a/packages/backend/src/utils/WSLUploader.ts b/packages/backend/src/utils/WSLUploader.ts new file mode 100644 index 000000000..d65b0be7b --- /dev/null +++ b/packages/backend/src/utils/WSLUploader.ts @@ -0,0 +1,74 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import * as podmanDesktopApi from '@podman-desktop/api'; +import { getFirstRunningMachineName, getPodmanCli } from './podman'; +import type { UploadWorker } from './uploader'; +import { getLocalModelFile, getRemoteModelFile, MACHINE_BASE_FOLDER } from './modelsUtils'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +export class WSLUploader implements UploadWorker { + canUpload(): boolean { + return podmanDesktopApi.env.isWindows; + } + + async upload(modelInfo: ModelInfo): Promise { + const localPath = getLocalModelFile(modelInfo); + + const driveLetter = localPath.charAt(0); + const convertToMntPath = localPath + .replace(`${driveLetter}:\\`, `/mnt/${driveLetter.toLowerCase()}/`) + .replace(/\\/g, '/'); + + const remoteFile = getRemoteModelFile(modelInfo); + const machineName = getFirstRunningMachineName(); + + if (!machineName) { + throw new Error('No podman machine is running'); + } + // check if model already loaded on the podman machine + let existsRemote = true; + try { + await podmanDesktopApi.process.exec(getPodmanCli(), ['machine', 'ssh', machineName, 'stat', remoteFile]); + } catch (e) { + existsRemote = false; + } + + // if not exists remotely it copies it from the local path + if (!existsRemote) { + await podmanDesktopApi.process.exec(getPodmanCli(), [ + 'machine', + 'ssh', + machineName, + 'mkdir', + '-p', + MACHINE_BASE_FOLDER, + ]); + await podmanDesktopApi.process.exec(getPodmanCli(), [ + 'machine', + 'ssh', + machineName, + 'cp', + convertToMntPath, + remoteFile, + ]); + } + + return remoteFile; + } +} diff --git a/packages/backend/src/utils/arch.ts b/packages/backend/src/utils/arch.ts new file mode 100644 index 000000000..52394c5e9 --- /dev/null +++ b/packages/backend/src/utils/arch.ts @@ -0,0 +1,31 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { arch } from 'node:os'; + +const nodeArch2GoArch = new Map([ + ['ia32', '386'], + ['x64', 'amd64'], +]); + +export function goarch(): string { + let localArch = arch(); + if (nodeArch2GoArch.has(localArch)) { + localArch = nodeArch2GoArch.get(localArch); + } + return localArch; +} diff --git a/packages/backend/src/utils/downloader.spec.ts b/packages/backend/src/utils/downloader.spec.ts new file mode 100644 index 000000000..cd3392e8f --- /dev/null +++ b/packages/backend/src/utils/downloader.spec.ts @@ -0,0 +1,128 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { vi, test, expect, beforeEach } from 'vitest'; +import { Downloader } from './downloader'; +import { EventEmitter } from '@podman-desktop/api'; +import { createWriteStream, promises, type WriteStream } from 'node:fs'; + +vi.mock('@podman-desktop/api', () => { + return { + EventEmitter: vi.fn(), + }; +}); + +vi.mock('node:https', () => { + return { + default: { + get: vi.fn(), + }, + }; +}); + +vi.mock('node:fs', () => { + return { + createWriteStream: vi.fn(), + existsSync: vi.fn(), + promises: { + rename: vi.fn(), + }, + }; +}); + +beforeEach(() => { + const listeners: ((value: unknown) => void)[] = []; + + vi.mocked(EventEmitter).mockReturnValue({ + event: vi.fn().mockImplementation(callback => { + listeners.push(callback); + }), + fire: vi.fn().mockImplementation((content: unknown) => { + listeners.forEach(listener => listener(content)); + }), + } as unknown as EventEmitter); +}); + +test('Downloader constructor', async () => { + const downloader = new Downloader('dummyUrl', 'dummyTarget'); + expect(downloader.getTarget()).toBe('dummyTarget'); +}); + +test('perform download failed', async () => { + const downloader = new Downloader('dummyUrl', 'dummyTarget'); + + const closeMock = vi.fn(); + const onMock = vi.fn(); + vi.mocked(createWriteStream).mockReturnValue({ + close: closeMock, + on: onMock, + } as unknown as WriteStream); + + onMock.mockImplementation((event: string, callback: () => void) => { + if (event === 'error') { + callback(); + } + }); + // capture downloader event(s) + const listenerMock = vi.fn(); + downloader.onEvent(listenerMock); + + // perform download logic + await downloader.perform('followUpId'); + + expect(downloader.completed).toBeTruthy(); + expect(listenerMock).toHaveBeenCalledWith({ + id: 'followUpId', + message: expect.anything(), + status: 'error', + }); +}); + +test('perform download successfully', async () => { + const downloader = new Downloader('dummyUrl', 'dummyTarget'); + vi.spyOn(promises, 'rename').mockResolvedValue(undefined); + + const closeMock = vi.fn(); + const onMock = vi.fn(); + vi.mocked(createWriteStream).mockReturnValue({ + close: closeMock, + on: onMock, + } as unknown as WriteStream); + + onMock.mockImplementation((event: string, callback: () => void) => { + if (event === 'finish') { + callback(); + } + }); + + // capture downloader event(s) + const listenerMock = vi.fn(); + downloader.onEvent(listenerMock); + + // perform download logic + await downloader.perform('followUpId'); + + expect(promises.rename).toHaveBeenCalledWith('dummyTarget.tmp', 'dummyTarget'); + expect(downloader.completed).toBeTruthy(); + expect(listenerMock).toHaveBeenCalledWith({ + id: 'followUpId', + duration: expect.anything(), + message: expect.anything(), + status: 'completed', + }); +}); diff --git a/packages/backend/src/utils/downloader.ts b/packages/backend/src/utils/downloader.ts new file mode 100644 index 000000000..a0de9daf7 --- /dev/null +++ b/packages/backend/src/utils/downloader.ts @@ -0,0 +1,138 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { getDurationSecondsSince } from './utils'; +import { createWriteStream, promises } from 'node:fs'; +import https from 'node:https'; +import { EventEmitter, type Event } from '@podman-desktop/api'; +import type { CompletionEvent, ProgressEvent, BaseEvent } from '../models/baseEvent'; + +export class Downloader { + private readonly _onEvent = new EventEmitter(); + readonly onEvent: Event = this._onEvent.event; + private requestedIdentifier: string; + + completed: boolean; + + constructor( + private url: string, + private target: string, + private abortSignal?: AbortSignal, + ) {} + + getTarget(): string { + return this.target; + } + + async perform(id: string) { + this.requestedIdentifier = id; + const startTime = performance.now(); + + try { + await this.download(this.url); + const durationSeconds = getDurationSecondsSince(startTime); + this._onEvent.fire({ + id: this.requestedIdentifier, + status: 'completed', + message: `Duration ${durationSeconds}s.`, + duration: durationSeconds, + } as CompletionEvent); + } catch (err: unknown) { + if (!this.abortSignal?.aborted) { + this._onEvent.fire({ + id: this.requestedIdentifier, + status: 'error', + message: `Something went wrong: ${String(err)}.`, + }); + } else { + this._onEvent.fire({ + id: this.requestedIdentifier, + status: 'canceled', + message: `Request cancelled: ${String(err)}.`, + }); + } + } finally { + this.completed = true; + } + } + + private download(url: string): Promise { + return new Promise((resolve, reject) => { + const callback = (result: { ok: boolean; error?: string }) => { + if (result.ok) { + resolve(); + } else { + reject(result.error); + } + }; + this.followRedirects(url, callback); + }); + } + + private followRedirects(url: string, callback: (message: { ok?: boolean; error?: string }) => void) { + const tmpFile = `${this.target}.tmp`; + const stream = createWriteStream(tmpFile); + + stream.on('finish', () => { + stream.close(); + // Rename from tmp to expected file name. + promises + .rename(tmpFile, this.target) + .then(() => { + callback({ ok: true }); + }) + .catch((err: unknown) => { + callback({ error: `Something went wrong while trying to rename downloaded file: ${String(err)}.` }); + }); + }); + stream.on('error', e => { + callback({ + error: e.message, + }); + }); + + let totalFileSize = 0; + let progress = 0; + https.get(url, { signal: this.abortSignal }, resp => { + if (resp.headers.location) { + this.followRedirects(resp.headers.location, callback); + return; + } else { + if (totalFileSize === 0 && resp.headers['content-length']) { + totalFileSize = parseFloat(resp.headers['content-length']); + } + } + + let previousProgressValue = -1; + resp.on('data', chunk => { + progress += chunk.length; + const progressValue = (progress * 100) / totalFileSize; + + if (progressValue === 100 || progressValue - previousProgressValue > 1) { + previousProgressValue = progressValue; + this._onEvent.fire({ + id: this.requestedIdentifier, + status: 'progress', + value: progressValue, + } as ProgressEvent); + } + }); + resp.pipe(stream); + }); + } +} diff --git a/packages/backend/src/utils/inferenceUtils.spec.ts b/packages/backend/src/utils/inferenceUtils.spec.ts new file mode 100644 index 000000000..7da9aaebd --- /dev/null +++ b/packages/backend/src/utils/inferenceUtils.spec.ts @@ -0,0 +1,135 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { vi, test, expect, describe, beforeEach } from 'vitest'; +import { + generateContainerCreateOptions, + withDefaultConfiguration, + INFERENCE_SERVER_IMAGE, + SECOND, +} from './inferenceUtils'; +import type { InferenceServerConfig } from '@shared/src/models/InferenceServerConfig'; +import type { ImageInfo } from '@podman-desktop/api'; +import { getFreeRandomPort } from './ports'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +vi.mock('./ports', () => ({ + getFreeRandomPort: vi.fn(), +})); + +beforeEach(() => { + vi.resetAllMocks(); + + vi.mocked(getFreeRandomPort).mockResolvedValue(8888); +}); + +describe('generateContainerCreateOptions', () => { + test('valid arguments', () => { + const result = generateContainerCreateOptions( + { + port: 8888, + providerId: 'test@providerId', + image: INFERENCE_SERVER_IMAGE, + modelsInfo: [ + { + id: 'dummyModelId', + file: { + file: 'dummyFile', + path: 'dummyPath', + }, + }, + ], + } as unknown as InferenceServerConfig, + { + Id: 'dummyImageId', + engineId: 'dummyEngineId', + RepoTags: [INFERENCE_SERVER_IMAGE], + } as unknown as ImageInfo, + ); + expect(result).toStrictEqual({ + Cmd: ['--models-path', '/models', '--context-size', '700', '--threads', '4'], + Detach: true, + Env: ['MODEL_PATH=/models/dummyFile'], + ExposedPorts: { + '8888': {}, + }, + HealthCheck: { + Interval: SECOND * 5, + Retries: 20, + Test: ['CMD-SHELL', 'curl -sSf localhost:8000/docs > /dev/null'], + }, + HostConfig: { + AutoRemove: false, + Mounts: [ + { + Source: 'dummyPath', + Target: '/models', + Type: 'bind', + }, + ], + PortBindings: { + '8000/tcp': [ + { + HostPort: '8888', + }, + ], + }, + SecurityOpt: ['label=disable'], + }, + Image: 'dummyImageId', + Labels: { + 'ai-studio-inference-server': '["dummyModelId"]', + }, + }); + }); +}); + +describe('withDefaultConfiguration', () => { + test('zero modelsInfo', async () => { + await expect(withDefaultConfiguration({ modelsInfo: [] })).rejects.toThrowError( + 'modelsInfo need to contain at least one element.', + ); + }); + + test('expect all default values', async () => { + const result = await withDefaultConfiguration({ modelsInfo: [{ id: 'dummyId' } as unknown as ModelInfo] }); + + expect(getFreeRandomPort).toHaveBeenCalledWith('0.0.0.0'); + + expect(result.port).toBe(8888); + expect(result.image).toBe(INFERENCE_SERVER_IMAGE); + expect(result.labels).toStrictEqual({}); + expect(result.providerId).toBe(undefined); + }); + + test('expect no default values', async () => { + const result = await withDefaultConfiguration({ + modelsInfo: [{ id: 'dummyId' } as unknown as ModelInfo], + port: 9999, + providerId: 'dummyProviderId', + image: 'random-image', + labels: { hello: 'world' }, + }); + + expect(getFreeRandomPort).not.toHaveBeenCalled(); + + expect(result.port).toBe(9999); + expect(result.image).toBe('random-image'); + expect(result.labels).toStrictEqual({ hello: 'world' }); + expect(result.providerId).toBe('dummyProviderId'); + }); +}); diff --git a/packages/backend/src/utils/inferenceUtils.ts b/packages/backend/src/utils/inferenceUtils.ts new file mode 100644 index 000000000..a1d7db2cd --- /dev/null +++ b/packages/backend/src/utils/inferenceUtils.ts @@ -0,0 +1,167 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { + containerEngine, + provider, + type ContainerCreateOptions, + type ContainerProviderConnection, + type PullEvent, + type ProviderContainerConnection, + type ImageInfo, + type ListImagesOptions, +} from '@podman-desktop/api'; +import type { CreationInferenceServerOptions, InferenceServerConfig } from '@shared/src/models/InferenceServerConfig'; +import { DISABLE_SELINUX_LABEL_SECURITY_OPTION } from './utils'; +import { getFreeRandomPort } from './ports'; + +export const SECOND: number = 1_000_000_000; + +export const LABEL_INFERENCE_SERVER: string = 'ai-studio-inference-server'; + +export const INFERENCE_SERVER_IMAGE = + 'ghcr.io/projectatomic/ai-studio-playground-images/ai-studio-playground-chat:0.1.0'; + +/** + * Return container connection provider + */ +export function getProviderContainerConnection(providerId?: string): ProviderContainerConnection { + // Get started providers + const providers = provider + .getContainerConnections() + .filter(connection => connection.connection.status() === 'started'); + + if (providers.length === 0) throw new Error('no engine started could be find.'); + + let output: ProviderContainerConnection | undefined = undefined; + + // If we expect a specific engine + if (providerId !== undefined) { + output = providers.find(engine => engine.providerId === providerId); + } else { + // Have a preference for a podman engine + output = providers.find(engine => engine.connection.type === 'podman'); + if (output === undefined) { + output = providers[0]; + } + } + if (output === undefined) throw new Error('cannot find any started container provider.'); + return output; +} + +/** + * Given an image name, it will return the ImageInspectInfo corresponding. Will raise an error if not found. + * @param connection + * @param image + * @param callback + */ +export async function getImageInfo( + connection: ContainerProviderConnection, + image: string, + callback: (event: PullEvent) => void, +): Promise { + let imageInfo: ImageInfo; + try { + // Pull image + await containerEngine.pullImage(connection, image, callback); + // Get image inspect + imageInfo = ( + await containerEngine.listImages({ + provider: connection, + } as ListImagesOptions) + ).find(imageInfo => imageInfo.RepoTags?.some(tag => tag === image)); + } catch (err: unknown) { + console.warn('Something went wrong while trying to get image inspect', err); + throw err; + } + + if (imageInfo === undefined) throw new Error(`image ${image} not found.`); + + return imageInfo; +} + +/** + * Given an {@link InferenceServerConfig} and an {@link ImageInfo} generate a container creation options object + * @param config the config to use + * @param imageInfo the image to use + */ +export function generateContainerCreateOptions( + config: InferenceServerConfig, + imageInfo: ImageInfo, +): ContainerCreateOptions { + if (config.modelsInfo.length === 0) throw new Error('Need at least one model info to start an inference server.'); + + if (config.modelsInfo.length > 1) { + throw new Error('Currently the inference server does not support multiple models serving.'); + } + + const modelInfo = config.modelsInfo[0]; + + if (modelInfo.file === undefined) { + throw new Error('The model info file provided is undefined'); + } + + return { + Image: imageInfo.Id, + Detach: true, + ExposedPorts: { [`${config.port}`]: {} }, + HostConfig: { + AutoRemove: false, + Mounts: [ + { + Target: '/models', + Source: modelInfo.file.path, + Type: 'bind', + }, + ], + SecurityOpt: [DISABLE_SELINUX_LABEL_SECURITY_OPTION], + PortBindings: { + '8000/tcp': [ + { + HostPort: `${config.port}`, + }, + ], + }, + }, + HealthCheck: { + // must be the port INSIDE the container not the exposed one + Test: ['CMD-SHELL', `curl -sSf localhost:8000/docs > /dev/null`], + Interval: SECOND * 5, + Retries: 4 * 5, + }, + Labels: { + ...config.labels, + [LABEL_INFERENCE_SERVER]: JSON.stringify(config.modelsInfo.map(model => model.id)), + }, + Env: [`MODEL_PATH=/models/${modelInfo.file.file}`], + Cmd: ['--models-path', '/models', '--context-size', '700', '--threads', '4'], + }; +} + +export async function withDefaultConfiguration( + options: CreationInferenceServerOptions, +): Promise { + if (options.modelsInfo.length === 0) throw new Error('modelsInfo need to contain at least one element.'); + + return { + port: options.port || (await getFreeRandomPort('0.0.0.0')), + image: options.image || INFERENCE_SERVER_IMAGE, + labels: options.labels || {}, + modelsInfo: options.modelsInfo, + providerId: options.providerId, + }; +} diff --git a/packages/backend/src/utils/modelsUtils.ts b/packages/backend/src/utils/modelsUtils.ts new file mode 100644 index 000000000..1ceb58b96 --- /dev/null +++ b/packages/backend/src/utils/modelsUtils.ts @@ -0,0 +1,41 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { join, posix } from 'node:path'; + +export const MACHINE_BASE_FOLDER = '/home/user/ai-studio/models/'; + +/** + * Given a model info object return the path where is it located locally + * @param modelInfo + */ +export function getLocalModelFile(modelInfo: ModelInfo): string { + if (modelInfo.file === undefined) throw new Error('model is not available locally.'); + return join(modelInfo.file.path, modelInfo.file.file); +} + +/** + * Given a model info object return the theoretical path where the model + * should be in the podman machine + * @param modelInfo + */ +export function getRemoteModelFile(modelInfo: ModelInfo): string { + if (modelInfo.file === undefined) throw new Error('model is not available locally.'); + + return posix.join(MACHINE_BASE_FOLDER, modelInfo.file.file); +} diff --git a/packages/backend/src/utils/podman.spec.ts b/packages/backend/src/utils/podman.spec.ts new file mode 100644 index 000000000..9948e38f7 --- /dev/null +++ b/packages/backend/src/utils/podman.spec.ts @@ -0,0 +1,280 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, expect, test, describe, vi } from 'vitest'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import * as utils from '../utils/podman'; + +const mocks = vi.hoisted(() => { + return { + getConfigurationMock: vi.fn(), + getContainerConnectionsMock: vi.fn(), + }; +}); + +const config: podmanDesktopApi.Configuration = { + get: mocks.getConfigurationMock, + has: () => true, + update: () => Promise.resolve(), +}; + +vi.mock('@podman-desktop/api', () => { + return { + env: { + isWindows: false, + }, + configuration: { + getConfiguration: () => config, + }, + provider: { + getContainerConnections: mocks.getContainerConnectionsMock, + }, + process: { + exec: vi.fn(), + }, + }; +}); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('getPodmanCli', () => { + test('should return custom binary path if setting is set', () => { + mocks.getConfigurationMock.mockReturnValue('binary'); + const result = utils.getPodmanCli(); + expect(result).equals('binary'); + }); + test('should return exe file if on windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = true; + mocks.getConfigurationMock.mockReturnValue(undefined); + const result = utils.getPodmanCli(); + expect(result).equals('podman.exe'); + }); + test('should return podman file if not on windows', () => { + vi.mocked(podmanDesktopApi.env).isWindows = false; + mocks.getConfigurationMock.mockReturnValue(undefined); + const result = utils.getPodmanCli(); + expect(result).equals('podman'); + }); +}); + +describe('getFirstRunningMachineName', () => { + test('return machine name if connection name does contain default Podman Machine name', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'Podman Machine', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).equals('podman-machine-default'); + }); + test('return machine name if connection name does contain custom Podman Machine name', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'Podman Machine test', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).equals('podman-machine-test'); + }); + test('return machine name if connection name does not contain Podman Machine', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'test', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).equals('test'); + }); + test('return undefined if there is no running connection', () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + const machineName = utils.getFirstRunningMachineName(); + expect(machineName).toBeUndefined(); + }); +}); + +describe('getFirstRunningPodmanConnection', () => { + test('should return undefined if failing at retrieving connection', async () => { + mocks.getConfigurationMock.mockRejectedValue('error'); + const result = utils.getFirstRunningPodmanConnection(); + expect(result).toBeUndefined(); + }); + test('should return undefined if default podman machine is not running', async () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + { + connection: { + name: 'machine2', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman2', + }, + ]); + const result = utils.getFirstRunningPodmanConnection(); + expect(result).toBeUndefined(); + }); + test('should return default running podman connection', async () => { + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'stopped', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + { + connection: { + name: 'machine2', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman2', + }, + ]); + const result = utils.getFirstRunningPodmanConnection(); + expect(result.connection.name).equal('machine2'); + }); +}); + +describe('isQEMUMachine', () => { + test('return true if qemu machine', async () => { + const machine: utils.MachineJSON = { + Name: 'machine', + CPUs: 2, + Memory: '2000', + DiskSize: '100', + Running: true, + Starting: false, + Default: true, + VMType: 'qemu', + }; + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + vi.mocked(podmanDesktopApi.env).isMac = true; + vi.spyOn(podmanDesktopApi.process, 'exec').mockResolvedValue({ + stdout: JSON.stringify([machine]), + } as podmanDesktopApi.RunResult); + const isQEMU = await utils.isQEMUMachine(); + expect(isQEMU).toBeTruthy(); + }); + test('return false if machine is not a mac one', async () => { + vi.mocked(podmanDesktopApi.env).isMac = false; + const isQEMU = await utils.isQEMUMachine(); + expect(isQEMU).toBeFalsy(); + }); + test('return false if non-qemu machine', async () => { + const machine: utils.MachineJSON = { + Name: 'machine', + CPUs: 2, + Memory: '2000', + DiskSize: '100', + Running: true, + Starting: false, + Default: true, + VMType: 'applehv', + }; + vi.mocked(podmanDesktopApi.env).isMac = true; + mocks.getContainerConnectionsMock.mockReturnValue([ + { + connection: { + name: 'machine', + status: () => 'started', + endpoint: { + socketPath: '/endpoint.sock', + }, + type: 'podman', + }, + providerId: 'podman', + }, + ]); + vi.spyOn(podmanDesktopApi.process, 'exec').mockResolvedValue({ + stdout: JSON.stringify([machine]), + } as podmanDesktopApi.RunResult); + const isQEMU = await utils.isQEMUMachine(); + expect(isQEMU).toBeFalsy(); + }); +}); diff --git a/packages/backend/src/utils/podman.ts b/packages/backend/src/utils/podman.ts new file mode 100644 index 000000000..879d2302c --- /dev/null +++ b/packages/backend/src/utils/podman.ts @@ -0,0 +1,120 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { ProviderContainerConnection } from '@podman-desktop/api'; +import { configuration, env, process, provider } from '@podman-desktop/api'; + +export type MachineJSON = { + Name: string; + CPUs: number; + Memory: string; + DiskSize: string; + Running: boolean; + Starting: boolean; + Default: boolean; + UserModeNetworking?: boolean; + VMType?: string; +}; + +export function getPodmanCli(): string { + // If we have a custom binary path regardless if we are running Windows or not + const customBinaryPath = getCustomBinaryPath(); + if (customBinaryPath) { + return customBinaryPath; + } + + if (env.isWindows) { + return 'podman.exe'; + } + return 'podman'; +} + +// Get the Podman binary path from configuration podman.binary.path +// return string or undefined +export function getCustomBinaryPath(): string | undefined { + return configuration.getConfiguration('podman').get('binary.path'); +} + +export function getFirstRunningMachineName(): string | undefined { + // the name of the podman connection is the name of the podman machine updated to make it more user friendly, + // so to retrieve the real machine name we need to revert the process + + // podman-machine-default -> Podman Machine + // podman-machine-{name} -> Podman Machine {name} + // {name} -> {name} + try { + const runningConnection = getFirstRunningPodmanConnection(); + const runningConnectionName = runningConnection.connection.name; + if (runningConnectionName.startsWith('Podman Machine')) { + const machineName = runningConnectionName.replace(/Podman Machine\s*/, 'podman-machine-'); + if (machineName.endsWith('-')) { + return `${machineName}default`; + } + return machineName; + } else { + return runningConnectionName; + } + } catch (e) { + console.log(e); + } + + return undefined; +} + +export function getFirstRunningPodmanConnection(): ProviderContainerConnection | undefined { + let engine: ProviderContainerConnection; + try { + engine = provider + .getContainerConnections() + .filter(connection => connection.connection.type === 'podman') + .find(connection => connection.connection.status() === 'started'); + } catch (e) { + console.log(e); + } + return engine; +} + +async function getJSONMachineList(): Promise { + const { stdout } = await process.exec(getPodmanCli(), ['machine', 'list', '--format', 'json']); + return stdout; +} + +export async function isQEMUMachine(): Promise { + try { + if (!env.isMac) { + return false; + } + + const runningMachineName = getFirstRunningMachineName(); + if (!runningMachineName) { + return false; + } + + const machineListOutput = await getJSONMachineList(); + const machines = JSON.parse(machineListOutput) as MachineJSON[]; + const runningMachine = machines.find(machine => machine.Name === runningMachineName); + if (!runningMachine) { + return false; + } + + return runningMachine.VMType === 'qemu'; + } catch (e) { + console.log(e); + } + + return false; +} diff --git a/packages/backend/src/utils/ports.ts b/packages/backend/src/utils/ports.ts index 9a5c33b00..77689d4d4 100644 --- a/packages/backend/src/utils/ports.ts +++ b/packages/backend/src/utils/ports.ts @@ -18,6 +18,27 @@ import * as net from 'net'; +export async function getFreeRandomPort(address: string): Promise { + const server = net.createServer(); + return new Promise((resolve, reject) => + server + .on('error', (error: NodeJS.ErrnoException) => reject(error)) + .on('listening', () => { + const addr = server.address(); + if (typeof addr === 'string') { + // this should not happen, as it is only for pipes and unix domain sockets + server.close(() => reject(new Error('error getting allocated port'))); + } else { + // not sure what the call to close will do on the addr value + // => the port value is saved before to call close + const allocatedPort = addr.port; + server.close(() => resolve(allocatedPort)); + } + }) + .listen(0, address), + ); +} + /** * Find a free port starting from the given port */ @@ -36,67 +57,26 @@ export async function getFreePort(port = 0): Promise { return port; } -/** - * Find a free port range - */ -export async function getFreePortRange(rangeSize: number): Promise { - let port = 9000; - let startPort = port; - - do { - if (await isFreePort(port)) { - ++port; - } else { - ++port; - startPort = port; - } - } while (port + 1 - startPort <= rangeSize); - - return `${startPort}-${port - 1}`; -} - -export function isFreePort(port: number): Promise { +function isFreeAddressPort(address: string, port: number): Promise { const server = net.createServer(); return new Promise((resolve, reject) => server .on('error', (error: NodeJS.ErrnoException) => (error.code === 'EADDRINUSE' ? resolve(false) : reject(error))) .on('listening', () => server.close(() => resolve(true))) - .listen(port, '127.0.0.1'), + .listen(port, address), ); } -export async function getPortsInfo(portDescriptor: string): Promise { - // check if portDescriptor is a range of ports - if (portDescriptor.includes('-')) { - return await getPortRange(portDescriptor); - } else { - const localPort = await getPort(portDescriptor); - if (!localPort) { - return undefined; - } - return `${localPort}`; - } +export async function isFreePort(port: number): Promise { + return (await isFreeAddressPort('127.0.0.1', port)) && (await isFreeAddressPort('0.0.0.0', port)); } -/** - * return a range of the same length as portDescriptor containing free ports - * undefined if the portDescriptor range is not valid - * e.g 5000:5001 -> 9000:9001 - */ -async function getPortRange(portDescriptor: string): Promise { - const rangeValues = getStartEndRange(portDescriptor); - if (!rangeValues) { - return Promise.resolve(undefined); - } - - const rangeSize = rangeValues.endRange + 1 - rangeValues.startRange; - try { - // if free port range fails, return undefined - return await getFreePortRange(rangeSize); - } catch (e) { - console.error(e); +export async function getPortsInfo(portDescriptor: string): Promise { + const localPort = await getPort(portDescriptor); + if (!localPort) { return undefined; } + return `${localPort}`; } async function getPort(portDescriptor: string): Promise { @@ -111,31 +91,9 @@ async function getPort(portDescriptor: string): Promise { return Promise.resolve(undefined); } try { - // if getFreePort fails, it returns undefined - return await getFreePort(port); + return await getFreeRandomPort('0.0.0.0'); } catch (e) { console.error(e); return undefined; } } - -function getStartEndRange(range: string) { - if (range.endsWith('/tcp') || range.endsWith('/udp')) { - range = range.substring(0, range.length - 4); - } - - const rangeValues = range.split('-'); - if (rangeValues.length !== 2) { - return undefined; - } - const startRange = parseInt(rangeValues[0]); - const endRange = parseInt(rangeValues[1]); - - if (isNaN(startRange) || isNaN(endRange)) { - return undefined; - } - return { - startRange, - endRange, - }; -} diff --git a/packages/backend/src/utils/randomUtils.ts b/packages/backend/src/utils/randomUtils.ts new file mode 100644 index 000000000..5fc930ea7 --- /dev/null +++ b/packages/backend/src/utils/randomUtils.ts @@ -0,0 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export const getRandomString = (): string => { + return (Math.random() + 1).toString(36).substring(7); +}; diff --git a/packages/backend/src/utils/recipeStatusUtils.ts b/packages/backend/src/utils/recipeStatusUtils.ts deleted file mode 100644 index 4e937c0bc..000000000 --- a/packages/backend/src/utils/recipeStatusUtils.ts +++ /dev/null @@ -1,74 +0,0 @@ -/********************************************************************** - * Copyright (C) 2024 Red Hat, Inc. - * - * 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. - * - * SPDX-License-Identifier: Apache-2.0 - ***********************************************************************/ - -import type { RecipeStatus, RecipeStatusState } from '@shared/src/models/IRecipeStatus'; -import type { Task, TaskState } from '@shared/src/models/ITask'; -import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; - -export class RecipeStatusUtils { - private tasks: Map = new Map(); - private state: RecipeStatusState = 'loading'; - - constructor( - private recipeId: string, - private recipeStatusRegistry: RecipeStatusRegistry, - ) {} - - update() { - this.recipeStatusRegistry.setStatus(this.recipeId, this.toRecipeStatus()); - } - - setStatus(state: RecipeStatusState): void { - this.state = state; - this.update(); - } - - setTask(task: Task) { - this.tasks.set(task.id, task); - - if (task.state === 'error') this.setStatus('error'); - - this.update(); - } - - setTaskState(taskId: string, state: TaskState) { - if (!this.tasks.has(taskId)) throw new Error('task not found.'); - const task = this.tasks.get(taskId); - this.setTask({ - ...task, - state: state, - }); - } - - setTaskProgress(taskId: string, value: number) { - if (!this.tasks.has(taskId)) throw new Error('task not found.'); - const task = this.tasks.get(taskId); - this.setTask({ - ...task, - progress: value, - }); - } - - toRecipeStatus(): RecipeStatus { - return { - recipeId: this.recipeId, - state: this.state, - tasks: Array.from(this.tasks.values()), - }; - } -} diff --git a/packages/backend/src/utils/uploader.spec.ts b/packages/backend/src/utils/uploader.spec.ts new file mode 100644 index 000000000..88f889232 --- /dev/null +++ b/packages/backend/src/utils/uploader.spec.ts @@ -0,0 +1,71 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, describe, vi } from 'vitest'; +import { WSLUploader } from './WSLUploader'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import { beforeEach } from 'node:test'; +import { Uploader } from './uploader'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +const mocks = vi.hoisted(() => { + return { + execMock: vi.fn(), + }; +}); + +vi.mock('@podman-desktop/api', async () => { + return { + env: { + isWindows: false, + }, + process: { + exec: mocks.execMock, + }, + EventEmitter: vi.fn().mockImplementation(() => { + return { + fire: vi.fn(), + }; + }), + }; +}); +const uploader = new Uploader({ + id: 'dummyModelId', + file: { + file: 'dummyFile.guff', + path: 'localpath', + }, +} as unknown as ModelInfo); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('perform', () => { + test('should return localModelPath if no workers for current system', async () => { + vi.mocked(podmanDesktopApi.env).isWindows = false; + const result = await uploader.perform('id'); + expect(result.startsWith('localpath')).toBeTruthy(); + }); + test('should return remote path if there is a worker for current system', async () => { + vi.spyOn(WSLUploader.prototype, 'upload').mockResolvedValue('remote'); + vi.mocked(podmanDesktopApi.env).isWindows = true; + const result = await uploader.perform('id'); + expect(result).toBe('remote'); + }); +}); diff --git a/packages/backend/src/utils/uploader.ts b/packages/backend/src/utils/uploader.ts new file mode 100644 index 000000000..6d49912eb --- /dev/null +++ b/packages/backend/src/utils/uploader.ts @@ -0,0 +1,99 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { EventEmitter, type Event } from '@podman-desktop/api'; +import { WSLUploader } from './WSLUploader'; +import { getDurationSecondsSince } from './utils'; +import type { CompletionEvent, BaseEvent } from '../models/baseEvent'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { getLocalModelFile } from './modelsUtils'; + +export interface UploadWorker { + canUpload: () => boolean; + upload: (modelInfo: ModelInfo) => Promise; +} + +export class Uploader { + readonly #_onEvent = new EventEmitter(); + readonly onEvent: Event = this.#_onEvent.event; + readonly #workers: UploadWorker[] = []; + + constructor( + private modelInfo: ModelInfo, + private abortSignal?: AbortSignal, + ) { + this.#workers = [new WSLUploader()]; + } + + /** + * Performing the upload action + * @param id tracking id + * + * @return the path to model after the operation (either on the podman machine or local if not compatible) + */ + async perform(id: string): Promise { + // Find the uploader for the current operating system + const worker: UploadWorker | undefined = this.#workers.find(w => w.canUpload()); + + // If none are found, we return the current path + if (worker === undefined) { + console.warn('There is no workers compatible. Using default local mounting'); + this.#_onEvent.fire({ + id, + status: 'completed', + message: `Use local model`, + } as CompletionEvent); + + return getLocalModelFile(this.modelInfo); + } + + try { + // measure performance + const startTime = performance.now(); + // get new path + const remotePath = await worker.upload(this.modelInfo); + // compute full time + const durationSeconds = getDurationSecondsSince(startTime); + // fire events + this.#_onEvent.fire({ + id, + status: 'completed', + message: `Duration ${durationSeconds}s.`, + duration: durationSeconds, + } as CompletionEvent); + + // return the new path on the podman machine + return remotePath; + } catch (err) { + if (!this.abortSignal?.aborted) { + this.#_onEvent.fire({ + id, + status: 'error', + message: `Something went wrong: ${String(err)}.`, + }); + } else { + this.#_onEvent.fire({ + id, + status: 'canceled', + message: `Request cancelled: ${String(err)}.`, + }); + } + throw new Error(`Unable to upload model. Error: ${String(err)}`); + } + } +} diff --git a/packages/backend/src/utils/utils.ts b/packages/backend/src/utils/utils.ts new file mode 100644 index 000000000..959de3cae --- /dev/null +++ b/packages/backend/src/utils/utils.ts @@ -0,0 +1,53 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import * as http from 'node:http'; + +export async function timeout(time: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, time); + }); +} + +export async function isEndpointAlive(endPoint: string): Promise { + return new Promise(resolve => { + const req = http.get(endPoint, res => { + res.on('data', () => { + // do nothing + }); + + res.on('end', () => { + console.log(res); + if (res.statusCode === 200) { + resolve(true); + } else { + resolve(false); + } + }); + }); + req.once('error', err => { + console.log('Error while pinging endpoint', err); + resolve(false); + }); + }); +} + +export function getDurationSecondsSince(startTimeMs: number) { + return Math.round((performance.now() - startTimeMs) / 1000); +} + +export const DISABLE_SELINUX_LABEL_SECURITY_OPTION = 'label=disable'; diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js index d86d61da3..7b5b3de1e 100644 --- a/packages/backend/vitest.config.js +++ b/packages/backend/vitest.config.js @@ -27,6 +27,7 @@ const config = { coverage: { provider: 'v8', reporter: ['lcov', 'text'], + extension: '.ts', }, }, resolve: { diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2ca9ed9bc..2a9e36091 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,15 +1,13 @@ { "name": "frontend-app", - "displayName": "UI for studio extension", - "version": "0.1.0-next", + "displayName": "UI for AI Studio", + "version": "0.3.0-next", "type": "module", "scripts": { "preview": "vite preview", "build": "vite build", "test": "vitest run --coverage", "test:watch": "vitest watch --coverage", - "format:check": "prettier --check \"src/**/*.ts\"", - "format:fix": "prettier --write \"src/**/*.ts\"", "watch": "vite --mode development build -w" }, "dependencies": { @@ -20,26 +18,26 @@ "@fortawesome/free-brands-svg-icons": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", - "@sveltejs/vite-plugin-svelte": "3.0.1", + "@sveltejs/vite-plugin-svelte": "3.0.2", "@tailwindcss/typography": "^0.5.10", - "@testing-library/dom": "^9.3.3", - "@testing-library/jest-dom": "^6.2.0", - "@testing-library/svelte": "^4.0.5", - "@testing-library/user-event": "^14.5.1", + "@testing-library/dom": "^9.3.4", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/svelte": "^4.1.0", + "@testing-library/user-event": "^14.5.2", "@tsconfig/svelte": "^5.0.2", "@types/humanize-duration": "^3.27.4", - "@typescript-eslint/eslint-plugin": "6.15.0", - "filesize": "^10.1.0", + "@typescript-eslint/eslint-plugin": "7.0.0", + "filesize": "^10.1.1", "humanize-duration": "^3.31.0", - "jsdom": "^23.2.0", + "jsdom": "^24.0.0", "moment": "^2.30.1", - "postcss": "^8.4.33", - "postcss-load-config": "^5.0.2", - "svelte": "4.2.8", - "svelte-fa": "^3.0.4", + "postcss": "^8.4.38", + "postcss-load-config": "^5.0.3", + "svelte": "4.2.12", + "svelte-fa": "^4.0.2", "svelte-markdown": "^0.4.1", "svelte-preprocess": "^5.1.3", - "tailwindcss": "^3.4.0", - "vitest": "^1.1.0" + "tailwindcss": "^3.4.1", + "vitest": "^1.4.0" } } diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 33d0d0435..98b90dacd 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -6,14 +6,19 @@ import Route from '/@/Route.svelte'; import Navigation from '/@/lib/Navigation.svelte'; import Dashboard from '/@/pages/Dashboard.svelte'; import Recipes from '/@/pages/Recipes.svelte'; -import Environments from '/@/pages/Environments.svelte'; +import Applications from './pages/Applications.svelte'; import Preferences from '/@/pages/Preferences.svelte'; -import Registries from '/@/pages/Registries.svelte'; import Models from '/@/pages/Models.svelte'; import Recipe from '/@/pages/Recipe.svelte'; import Model from './pages/Model.svelte'; import { onMount } from 'svelte'; import { getRouterState } from '/@/utils/client'; +import CreateService from '/@/pages/CreateService.svelte'; +import Services from '/@/pages/InferenceServers.svelte'; +import ServiceDetails from '/@/pages/InferenceServerDetails.svelte'; +import Playgrounds from './pages/Playgrounds.svelte'; +import Playground from './pages/Playground.svelte'; +import PlaygroundCreate from './pages/PlaygroundCreate.svelte'; router.mode.hash(); @@ -27,48 +32,65 @@ onMount(() => { }); -
- + - + - + - - - + + + - - - + + + - - - - + + {#if meta.params.id === 'create'} + + {:else} + + {/if} - + + + + + + + + + + + + + - - + + - - + + {#if meta.params.id === 'create'} + + {:else} + + {/if}
diff --git a/packages/frontend/src/Route.svelte b/packages/frontend/src/Route.svelte index c45d45c14..a200cebcb 100644 --- a/packages/frontend/src/Route.svelte +++ b/packages/frontend/src/Route.svelte @@ -27,8 +27,8 @@ const route = createRouteObject({ meta = newMeta; params = meta.params; - if(isAppMounted) { - saveRouterState({url: newMeta.url}); + if (isAppMounted) { + saveRouterState({ url: newMeta.url }); } }, }); diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index a67ff8f5c..98d5f3a6e 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -3,7 +3,7 @@ - Studio extension + AI Studio diff --git a/packages/frontend/src/lib/ApplicationActions.svelte b/packages/frontend/src/lib/ApplicationActions.svelte new file mode 100644 index 000000000..8d10105e0 --- /dev/null +++ b/packages/frontend/src/lib/ApplicationActions.svelte @@ -0,0 +1,35 @@ + + +{#if object?.pod !== undefined} + + + + + +{/if} diff --git a/packages/frontend/src/lib/Card.svelte b/packages/frontend/src/lib/Card.svelte index 11ae9e1aa..27dfc1c34 100644 --- a/packages/frontend/src/lib/Card.svelte +++ b/packages/frontend/src/lib/Card.svelte @@ -1,35 +1,43 @@
-
+
{#if icon} - {/if} - {#if title} -
-
+
+ {#if title} +
{title}
-
- {/if} + {/if} + {#if description} +
+ {description} +
+ {/if} +
@@ -37,4 +45,3 @@ export let primaryBackground: string = "bg-charcoal-800"
- diff --git a/packages/frontend/src/lib/Checkbox.svelte b/packages/frontend/src/lib/Checkbox.svelte index cf2ba2b17..f70c44d40 100644 --- a/packages/frontend/src/lib/Checkbox.svelte +++ b/packages/frontend/src/lib/Checkbox.svelte @@ -31,13 +31,13 @@ function onClick(checked: boolean) { class:cursor-pointer="{!disabled}" class:cursor-not-allowed="{disabled}"> {#if disabled} - + {:else if indeterminate} - + {:else if checked} - + {:else} - + {/if}
diff --git a/packages/frontend/src/lib/ContentDetailsLayout.spec.ts b/packages/frontend/src/lib/ContentDetailsLayout.spec.ts new file mode 100644 index 000000000..18a3a9ba1 --- /dev/null +++ b/packages/frontend/src/lib/ContentDetailsLayout.spec.ts @@ -0,0 +1,45 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { expect, test } from 'vitest'; +import ContentDetailsLayoutTest from './ContentDetailsLayoutTest.svelte'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; + +test('should open/close details panel when clicking on toggle button', async () => { + render(ContentDetailsLayoutTest); + + const panelOpenDetails = screen.getByLabelText('toggle a label'); + expect(panelOpenDetails).toHaveClass('hidden'); + const panelAppDetails = screen.getByLabelText('a label panel'); + expect(panelAppDetails).toHaveClass('block'); + + const btnShowPanel = screen.getByRole('button', { name: 'show a label' }); + const btnHidePanel = screen.getByRole('button', { name: 'hide a label' }); + + await userEvent.click(btnHidePanel); + + expect(panelAppDetails).toHaveClass('hidden'); + expect(panelOpenDetails).toHaveClass('block'); + + await userEvent.click(btnShowPanel); + + expect(panelAppDetails).toHaveClass('block'); + expect(panelOpenDetails).toHaveClass('hidden'); +}); diff --git a/packages/frontend/src/lib/ContentDetailsLayout.svelte b/packages/frontend/src/lib/ContentDetailsLayout.svelte new file mode 100644 index 000000000..9e5a0fa59 --- /dev/null +++ b/packages/frontend/src/lib/ContentDetailsLayout.svelte @@ -0,0 +1,41 @@ + + +
+
+ +
+
+
+
+
+
+ {detailsTitle} + +
+ +
+
+
+ +
+
+
+
diff --git a/packages/frontend/src/lib/ContentDetailsLayoutTest.svelte b/packages/frontend/src/lib/ContentDetailsLayoutTest.svelte new file mode 100644 index 000000000..ce91cf0e6 --- /dev/null +++ b/packages/frontend/src/lib/ContentDetailsLayoutTest.svelte @@ -0,0 +1,8 @@ + + + + A Content + Details... + diff --git a/packages/frontend/src/lib/ErrorMessage.svelte b/packages/frontend/src/lib/ErrorMessage.svelte new file mode 100644 index 000000000..0ed44120f --- /dev/null +++ b/packages/frontend/src/lib/ErrorMessage.svelte @@ -0,0 +1,23 @@ + + +{#if icon} + {#if error !== undefined && error !== ''} + + + + {/if} +{:else} +
+ + +
+{/if} diff --git a/packages/frontend/src/lib/ExpandableMessage.svelte b/packages/frontend/src/lib/ExpandableMessage.svelte new file mode 100644 index 000000000..07dbecf4a --- /dev/null +++ b/packages/frontend/src/lib/ExpandableMessage.svelte @@ -0,0 +1,19 @@ + + +{#if message} +
{message}
+
+ +
+{/if} diff --git a/packages/frontend/src/lib/Modal.svelte b/packages/frontend/src/lib/Modal.svelte new file mode 100644 index 000000000..16c169244 --- /dev/null +++ b/packages/frontend/src/lib/Modal.svelte @@ -0,0 +1,62 @@ + + + + + + + + + diff --git a/packages/frontend/src/lib/NavPage.spec.ts b/packages/frontend/src/lib/NavPage.spec.ts index fde5d0da0..e5545367e 100644 --- a/packages/frontend/src/lib/NavPage.spec.ts +++ b/packages/frontend/src/lib/NavPage.spec.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import '@testing-library/jest-dom/vitest'; import { test, expect } from 'vitest'; import { render, screen } from '@testing-library/svelte'; @@ -20,3 +38,12 @@ test('NavPage should not have linear progress', async () => { expect(content).toBeDefined(); expect(content.firstChild).toBeNull(); // no slot content provided }); + +test('NavPage should have custom background', async () => { + // render the component + render(NavPage, { title: 'dummy', contentBackground: 'bg-white' }); + + const content = await screen.findByLabelText('content'); + expect(content).toBeDefined(); + expect(content).toHaveClass('bg-white'); +}); diff --git a/packages/frontend/src/lib/NavPage.svelte b/packages/frontend/src/lib/NavPage.svelte index 04a7a03dd..f8f706d65 100644 --- a/packages/frontend/src/lib/NavPage.svelte +++ b/packages/frontend/src/lib/NavPage.svelte @@ -8,59 +8,65 @@ export let searchTerm = ''; export let searchEnabled = true; export let loading = false; export let icon: IconDefinition | undefined = undefined; +export let contentBackground = ''; -
+
-
-
- {#if icon} -
- -
- {/if} -

{title}

+
+
+ {#if icon} +
+ +
+ {/if} +

{title}

+
+
+ {#if $$slots['additional-actions']} +
+ {:else} {/if} +
-
+ +
+
-
- {#if searchEnabled} -
-
-
- - - - -
-
-
- {/if} + {#if searchEnabled} +
+
+
+ + + + +
+ {/if}
-
+
{#if loading} - + {:else} {/if} diff --git a/packages/frontend/src/lib/Navigation.svelte b/packages/frontend/src/lib/Navigation.svelte index 6056e8601..a9010973b 100644 --- a/packages/frontend/src/lib/Navigation.svelte +++ b/packages/frontend/src/lib/Navigation.svelte @@ -11,20 +11,21 @@ export let meta: TinroRouteMeta; class="z-1 w-leftsidebar min-w-leftsidebar shadow flex-col justify-between flex transition-all duration-500 ease-in-out bg-charcoal-800" aria-label="PreferencesNavigation">
- - + - + + +
diff --git a/packages/frontend/src/lib/RangeInput.svelte b/packages/frontend/src/lib/RangeInput.svelte new file mode 100644 index 000000000..745deda62 --- /dev/null +++ b/packages/frontend/src/lib/RangeInput.svelte @@ -0,0 +1,34 @@ + + +
+
+ {name} + +
+
+ +
+
diff --git a/packages/frontend/src/lib/RecipeDetails.spec.ts b/packages/frontend/src/lib/RecipeDetails.spec.ts new file mode 100644 index 000000000..caabd6bc4 --- /dev/null +++ b/packages/frontend/src/lib/RecipeDetails.spec.ts @@ -0,0 +1,246 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { vi, test, expect, beforeEach } from 'vitest'; +import { screen, render } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog'; +import * as catalogStore from '/@/stores/catalog'; +import { readable } from 'svelte/store'; +import RecipeDetails from './RecipeDetails.svelte'; +import { router } from 'tinro'; + +const mocks = vi.hoisted(() => { + return { + pullApplicationMock: vi.fn(), + getApplicationsStateMock: vi.fn(), + findMock: vi.fn(), + getLocalRepositoriesMock: vi.fn(), + getTasksMock: vi.fn(), + }; +}); + +vi.mock('../utils/client', async () => { + return { + studioClient: { + pullApplication: mocks.pullApplicationMock, + getApplicationsState: mocks.getApplicationsStateMock, + }, + rpcBrowser: { + subscribe: () => { + return { + unsubscribe: () => {}, + }; + }, + }, + }; +}); + +vi.mock('../stores/tasks', () => ({ + tasks: { + subscribe: (f: (msg: any) => void) => { + f(mocks.getTasksMock()); + return () => {}; + }, + }, +})); + +vi.mock('/@/stores/catalog', async () => { + return { + catalog: vi.fn(), + }; +}); + +vi.mock('../stores/localRepositories', () => ({ + localRepositories: { + subscribe: (f: (msg: any) => void) => { + f(mocks.getLocalRepositoriesMock()); + return () => {}; + }, + }, +})); + +const initialCatalog: ApplicationCatalog = { + categories: [], + models: [ + { + id: 'model1', + name: 'Model 1', + description: 'Readme for model 1', + hw: 'CPU', + registry: 'Hugging Face', + license: '?', + url: 'https://model1.example.com', + }, + { + id: 'model2', + name: 'Model 2', + description: 'Readme for model 2', + hw: 'CPU', + registry: 'Civital', + license: '?', + url: '', + }, + ], + recipes: [ + { + id: 'recipe 1', + name: 'Recipe 1', + readme: 'readme 1', + categories: [], + models: ['model1', 'model2'], + description: 'description 1', + repository: 'repo 1', + }, + { + id: 'recipe 2', + name: 'Recipe 2', + readme: 'readme 2', + categories: [], + description: 'description 2', + repository: 'repo 2', + }, + ], +}; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.getLocalRepositoriesMock.mockReturnValue([]); + mocks.getTasksMock.mockReturnValue([]); +}); + +test('should call runApplication execution when run application button is clicked', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + mocks.pullApplicationMock.mockResolvedValue(undefined); + render(RecipeDetails, { + recipeId: 'recipe 1', + modelId: 'model1', + }); + + const btnRunApplication = screen.getByText('Start AI App'); + await userEvent.click(btnRunApplication); + + expect(mocks.pullApplicationMock).toBeCalledWith('recipe 1', 'model1'); +}); + +test('swap model button should move user to models tab', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + const gotoMock = vi.spyOn(router, 'goto'); + render(RecipeDetails, { + recipeId: 'recipe 1', + modelId: 'model1', + }); + + const btnSwap = screen.getByRole('button', { name: 'Go to Model' }); + await userEvent.click(btnSwap); + + expect(gotoMock).toBeCalledWith('/recipe/recipe 1/models'); +}); + +test('swap model panel should be hidden on models tab', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(RecipeDetails, { + recipeId: 'recipe 1', + modelId: 'model1', + }); + + // swap model panel is visible + const swapModelPanel = screen.getByLabelText('swap model panel'); + expect(!swapModelPanel.classList.contains('hidden')); + + // click the swap panel to switch to the model tab + const btnSwap = screen.getByRole('button', { name: 'Go to Model' }); + await userEvent.click(btnSwap); + + await new Promise(resolve => setTimeout(resolve, 200)); + // the swap model panel should be hidden + const swapModelPanel2 = screen.getByLabelText('swap model panel'); + expect(swapModelPanel2.classList.contains('hidden')); +}); + +test('should display default model information when model is the recommended', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(RecipeDetails, { + recipeId: 'recipe 1', + modelId: 'model1', + }); + + const modelInfo = screen.getByLabelText('model-selected'); + expect(modelInfo.textContent).equal('Model 1'); + const licenseBadge = screen.getByLabelText('license-model'); + expect(licenseBadge.textContent).equal('?'); + const defaultWarning = screen.getByLabelText('model-warning'); + expect(defaultWarning.textContent).contains('This is the default, recommended model for this recipe.'); +}); + +test('should display non-default model information when model is not the recommended one', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(RecipeDetails, { + recipeId: 'recipe 1', + modelId: 'model2', + }); + + const modelInfo = screen.getByLabelText('model-selected'); + expect(modelInfo.textContent).equal('Model 2'); + const defaultWarning = screen.getByLabelText('model-warning'); + expect(defaultWarning.textContent).contains('The default model for this recipe is'); +}); + +test('button vs code should be visible if local repository is not empty', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + mocks.getLocalRepositoriesMock.mockReturnValue([ + { + path: 'random-path', + labels: { + 'recipe-id': 'recipe 1', + }, + }, + ]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(RecipeDetails, { + recipeId: 'recipe 1', + modelId: 'model2', + }); + + const button = screen.getByTitle('Open in VS Code Desktop'); + expect(button).toBeDefined(); +}); + +test('start application button should be the only one displayed', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(RecipeDetails, { + recipeId: 'recipe 1', + modelId: 'model1', + }); + + const btnRunApplication = screen.getByText('Start AI App'); + expect(btnRunApplication).toBeInTheDocument(); + + const btnStop = screen.queryByTitle('Stop AI App'); + expect(btnStop).toBeNull(); + + const btnRestart = screen.queryByTitle('Restart AI App'); + expect(btnRestart).toBeNull(); +}); diff --git a/packages/frontend/src/lib/RecipeDetails.svelte b/packages/frontend/src/lib/RecipeDetails.svelte new file mode 100644 index 000000000..7222a015a --- /dev/null +++ b/packages/frontend/src/lib/RecipeDetails.svelte @@ -0,0 +1,160 @@ + + +
+
+ {#if appState && appState.pod} +
+ + + +
+
+ {appState.pod.Name} +
+
+ {appState.pod.Status.toUpperCase()} +
+
+
+
+ +
+ {:else} + + {/if} +
+ {#if filteredTasks.length > 0} +
+ +
+ {/if} +
+
+ {#if model} +
+
+
Model
+
+
+
+
+
+ {model?.name} + {#if recipe?.models?.[0] === model.id} + + {/if} +
+ {#if model?.license} +
+
+ {model.license} +
+
+ {/if} +
+
+ {#if recipe?.models?.[0] === model.id} + * This is the default, recommended model for this recipe. You can swap for a different compatible model. + {:else} + * The default model for this recipe is {recipe?.models?.[0]}. You can + swap for {recipe?.models?.[0]} or a different compatible model. + {/if} +
+
+ {/if} +
+
Repository
+
+ +
+
+ {#if localPath} + + {/if} +
diff --git a/packages/frontend/src/lib/RecipesCard.svelte b/packages/frontend/src/lib/RecipesCard.svelte index 249b1627f..dab03f3b1 100644 --- a/packages/frontend/src/lib/RecipesCard.svelte +++ b/packages/frontend/src/lib/RecipesCard.svelte @@ -4,44 +4,40 @@ import { getIcon } from '/@/utils/categoriesUtils'; import type { Category } from '@shared/src/models/ICategory'; import { catalog } from '/@/stores/catalog'; -export let category: Category +export let category: Category; $: categories = $catalog.categories; -$: recipes = $catalog.recipes.filter(r => r.categories.includes(category.id)).map(r => ({...r, icon: category.id})); +$: recipes = $catalog.recipes.filter(r => r.categories.includes(category.id)); -export let primaryBackground: string = "bg-charcoal-800" -export let secondaryBackground: string = "bg-charcoal-700" +export let primaryBackground: string = 'bg-charcoal-800'; +export let secondaryBackground: string = 'bg-charcoal-700'; -export let displayCategory: boolean = true -export let displayDescription: boolean = true +export let displayCategory: boolean = true; +export let displayDescription: boolean = true; - +
-
- {#if recipes.length === 0} - There is no models in this category for now ! Come back later - {/if} + {#if recipes.length === 0} +
There is no recipe in this category for now ! Come back later
+ {/if} +
{#each recipes as recipe} + classes="{secondaryBackground} flex-grow p-4 h-full">
{#if displayCategory} {#each recipe.categories as categoryId} + primaryBackground="{primaryBackground}" /> {/each} {/if} - {#if displayDescription} - {recipe.description} - {/if}
{/each} diff --git a/packages/frontend/src/lib/SettingsNavItem.svelte b/packages/frontend/src/lib/SettingsNavItem.svelte index 22b1cdb39..fcd0407a9 100644 --- a/packages/frontend/src/lib/SettingsNavItem.svelte +++ b/packages/frontend/src/lib/SettingsNavItem.svelte @@ -1,6 +1,6 @@ + +
+
+ {#if status === 'DELETING'} + + {:else if typeof icon === 'string'} + + {:else} + + {/if} +
+
diff --git a/packages/frontend/src/lib/button/Button.spec.ts b/packages/frontend/src/lib/button/Button.spec.ts new file mode 100644 index 000000000..d9cbcc7a4 --- /dev/null +++ b/packages/frontend/src/lib/button/Button.spec.ts @@ -0,0 +1,38 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Button from '/@/lib/button/Button.svelte'; + +test('Button inProgress must have a spinner', async () => { + // render the component + render(Button, { inProgress: true }); + + const svg = screen.getByRole('img'); + expect(svg).toBeDefined(); +}); + +test('Button no progress no icon do not have spinner', async () => { + // render the component + render(Button, { inProgress: false }); + + const svg = screen.queryByRole('img'); + expect(svg).toBeNull(); +}); diff --git a/packages/frontend/src/lib/button/Button.svelte b/packages/frontend/src/lib/button/Button.svelte index 6aafe58fe..c1b97b2d6 100644 --- a/packages/frontend/src/lib/button/Button.svelte +++ b/packages/frontend/src/lib/button/Button.svelte @@ -19,7 +19,7 @@ export let padding: string = type !== 'tab' ? 'px-4 py-[5px]' : 'px-4 pb-1'; let iconType: string | undefined = undefined; onMount(() => { - if (icon?.prefix === 'fas') { + if (['fas', 'fab'].includes(icon?.prefix)) { iconType = 'fa'; } else { iconType = 'unknown'; @@ -66,14 +66,14 @@ $: { aria-label="{$$props['aria-label']}" on:click disabled="{disabled || inProgress}"> - {#if icon} + {#if icon || inProgress}
{#if inProgress} {:else if iconType === 'fa'} {:else if iconType === 'unknown'} - + {/if} {#if $$slots.default} diff --git a/packages/frontend/src/lib/button/ListItemButtonIcon.svelte b/packages/frontend/src/lib/button/ListItemButtonIcon.svelte new file mode 100644 index 000000000..8d31a781d --- /dev/null +++ b/packages/frontend/src/lib/button/ListItemButtonIcon.svelte @@ -0,0 +1,58 @@ + + + diff --git a/packages/frontend/src/lib/dialog-utils.ts b/packages/frontend/src/lib/dialog-utils.ts new file mode 100644 index 000000000..f64d301e5 --- /dev/null +++ b/packages/frontend/src/lib/dialog-utils.ts @@ -0,0 +1,32 @@ +/********************************************************************** + * Copyright (C) 2023-2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export function tabWithinParent(e: KeyboardEvent, parent: HTMLDivElement): void { + // trap focus within parent element + const nodes = parent.querySelectorAll('*'); + const tabbable = Array.from(nodes).filter(n => n.tabIndex >= 0); + + let index = tabbable.indexOf(document.activeElement as HTMLElement); + if (index === -1 && e.shiftKey) index = 0; + + index += tabbable.length + (e.shiftKey ? -1 : 1); + index %= tabbable.length; + + tabbable[index].focus(); + e.preventDefault(); +} diff --git a/packages/frontend/src/lib/images/ContainerIcon.svelte b/packages/frontend/src/lib/images/ContainerIcon.svelte new file mode 100644 index 000000000..7b4bbce47 --- /dev/null +++ b/packages/frontend/src/lib/images/ContainerIcon.svelte @@ -0,0 +1,45 @@ + + + + {#if solid} + + + + + + + + + + + + {:else} + + + + + + {/if} + diff --git a/packages/frontend/src/lib/images/PodIcon.svelte b/packages/frontend/src/lib/images/PodIcon.svelte new file mode 100644 index 000000000..1b2ddc0c2 --- /dev/null +++ b/packages/frontend/src/lib/images/PodIcon.svelte @@ -0,0 +1,41 @@ + + + + {#if solid} + + + + + + + + + + {:else} + + + + {/if} + diff --git a/packages/frontend/src/lib/images/VSCodeIcon.svelte b/packages/frontend/src/lib/images/VSCodeIcon.svelte new file mode 100644 index 000000000..ce059c2a9 --- /dev/null +++ b/packages/frontend/src/lib/images/VSCodeIcon.svelte @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/src/lib/markdown/LinkComponent.svelte b/packages/frontend/src/lib/markdown/LinkComponent.svelte index 60811fe8c..3e326d4ce 100644 --- a/packages/frontend/src/lib/markdown/LinkComponent.svelte +++ b/packages/frontend/src/lib/markdown/LinkComponent.svelte @@ -1,13 +1,14 @@ + -{text} +{text} diff --git a/packages/frontend/src/lib/markdown/MarkdownRenderer.svelte b/packages/frontend/src/lib/markdown/MarkdownRenderer.svelte index e5a1d19d1..fb381c65d 100644 --- a/packages/frontend/src/lib/markdown/MarkdownRenderer.svelte +++ b/packages/frontend/src/lib/markdown/MarkdownRenderer.svelte @@ -1,10 +1,9 @@
- +
- diff --git a/packages/frontend/src/lib/progress/TaskElement.svelte b/packages/frontend/src/lib/progress/TaskElement.svelte new file mode 100644 index 000000000..1a2137f3c --- /dev/null +++ b/packages/frontend/src/lib/progress/TaskElement.svelte @@ -0,0 +1,51 @@ + + +
+
+ {#if task.state === 'success'} + + + + {:else if task.state === 'loading'} + + + + + {:else} + + + + {/if} +
+ + {task.name} + {#if task.progress}({Math.floor(task.progress)}%){/if} + +
diff --git a/packages/frontend/src/lib/progress/TaskItem.svelte b/packages/frontend/src/lib/progress/TaskItem.svelte new file mode 100644 index 000000000..1a2137f3c --- /dev/null +++ b/packages/frontend/src/lib/progress/TaskItem.svelte @@ -0,0 +1,51 @@ + + +
+
+ {#if task.state === 'success'} + + + + {:else if task.state === 'loading'} + + + + + {:else} + + + + {/if} +
+ + {task.name} + {#if task.progress}({Math.floor(task.progress)}%){/if} + +
diff --git a/packages/frontend/src/lib/progress/TasksProgress.spec.ts b/packages/frontend/src/lib/progress/TasksProgress.spec.ts new file mode 100644 index 000000000..1abd9b94a --- /dev/null +++ b/packages/frontend/src/lib/progress/TasksProgress.spec.ts @@ -0,0 +1,182 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect, describe } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import TasksProgress from '/@/lib/progress/TasksProgress.svelte'; + +test('TasksProgress should not renderer any tasks', async () => { + // render the component + render(TasksProgress, { tasks: [] }); + + const items = screen.queryAllByRole('listitem'); + expect(items).toBeDefined(); + expect(items.length).toBe(0); +}); + +test('TasksProgress should renderer one tasks', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'success', + name: 'random', + }, + ], + }); + + const items = screen.queryAllByRole('listitem'); + expect(items).toBeDefined(); + expect(items.length).toBe(1); +}); + +test('TasksProgress should renderer multiple tasks', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'success', + name: 'random', + }, + { + id: 'random-2', + state: 'error', + name: 'random', + }, + ], + }); + + const items = screen.queryAllByRole('listitem'); + expect(items).toBeDefined(); + expect(items.length).toBe(2); +}); + +describe('tasks types', () => { + test('TasksProgress should renderer success task', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'success', + name: 'random', + }, + ], + }); + + const item = screen.getByRole('img'); + expect(item).toHaveClass('text-green-500'); + expect(item).not.toHaveClass('animate-spin'); + }); + + test('TasksProgress should renderer loading task', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'loading', + name: 'random', + }, + ], + }); + + const item = screen.getByRole('img'); + expect(item).toHaveClass('fill-purple-500'); + expect(item).toHaveClass('animate-spin'); + }); + + test('TasksProgress should renderer error task', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'error', + name: 'random', + }, + ], + }); + + const item = screen.getByRole('img'); + expect(item).toHaveClass('text-red-600'); + expect(item).not.toHaveClass('animate-spin'); + }); +}); + +describe('error expandable', () => { + test('TasksProgress should renderer one error task without expandable error message', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'error', + name: 'random', + }, + ], + }); + + const message = screen.queryByText('View Error'); + expect(message).toBeNull(); + }); + + test('TasksProgress should renderer one error task without showing error message', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'error', + name: 'random', + error: 'message about error.', + }, + ], + }); + + const message = screen.queryByText('View Error'); + expect(message).toBeDefined(); + const note = screen.getByRole('note'); + expect(note).toHaveClass('hidden'); + }); + + test('TasksProgress should renderer one error task and show error message on click', async () => { + // render the component + render(TasksProgress, { + tasks: [ + { + id: 'random', + state: 'error', + name: 'random', + error: 'message about error.', + }, + ], + }); + + const message = screen.getByText('View Error'); + await fireEvent.click(message); + + const note = screen.getByRole('note'); + await waitFor(() => { + expect(note).not.toHaveClass('hidden'); + }); + }); +}); diff --git a/packages/frontend/src/lib/progress/TasksProgress.svelte b/packages/frontend/src/lib/progress/TasksProgress.svelte index c4aa2b2db..dea6bcfd0 100644 --- a/packages/frontend/src/lib/progress/TasksProgress.svelte +++ b/packages/frontend/src/lib/progress/TasksProgress.svelte @@ -1,37 +1,16 @@ -
    - +
      {#each tasks as task} -
    • -
      - {#if task.state === 'success'} - - {:else if task.state === 'loading'} - - {:else} - - {/if} -
      - - {task.name} {#if task.progress}({Math.floor(task.progress)}%){/if} - +
    • + +
    • {/each}
    diff --git a/packages/frontend/src/lib/table/Table.spec.ts b/packages/frontend/src/lib/table/Table.spec.ts index ce215d34e..0e82b3d51 100644 --- a/packages/frontend/src/lib/table/Table.spec.ts +++ b/packages/frontend/src/lib/table/Table.spec.ts @@ -232,3 +232,23 @@ test('Expect overflow-hidden', async () => { expect(cells[5]).toHaveClass('overflow-hidden'); } }); + +test('Expect custom background', async () => { + render(TestTable, { + headerBackground: 'bg-white', + }); + + // get the 4 rows (first is header) + const rows = await screen.findAllByRole('row'); + expect(rows).toBeDefined(); + expect(rows[0]).toHaveClass('bg-white'); +}); + +test('Expect default background', async () => { + render(TestTable, {}); + + // get the 4 rows (first is header) + const rows = await screen.findAllByRole('row'); + expect(rows).toBeDefined(); + expect(rows[0]).toHaveClass('bg-charcoal-700'); +}); diff --git a/packages/frontend/src/lib/table/Table.svelte b/packages/frontend/src/lib/table/Table.svelte index d10444d8b..33a9c2637 100644 --- a/packages/frontend/src/lib/table/Table.svelte +++ b/packages/frontend/src/lib/table/Table.svelte @@ -18,6 +18,7 @@ export let data: any[]; export let columns: Column[]; export let row: Row; export let defaultSortColumn: string | undefined = undefined; +export let headerBackground = 'bg-charcoal-600'; // number of selected items in the list export let selectedItemsNumber: number = 0; @@ -125,7 +126,7 @@ function setGridColumns() {
    {#if row.info.selectable} @@ -165,7 +166,7 @@ function setGridColumns() {
    {#each data as object (object)}
    @@ -189,7 +190,8 @@ function setGridColumns() { {#if column.info.renderer} + object="{column.info.renderMapping?.(object) ?? object}" + on:update /> {/if}
    {/each} diff --git a/packages/frontend/src/lib/table/TestTable.svelte b/packages/frontend/src/lib/table/TestTable.svelte index 84a67f4a1..d6453f490 100644 --- a/packages/frontend/src/lib/table/TestTable.svelte +++ b/packages/frontend/src/lib/table/TestTable.svelte @@ -5,6 +5,7 @@ import SimpleColumn from './SimpleColumn.svelte'; let table: Table; let selectedItemsNumber: number; +export let headerBackground = 'bg-charcoal-700'; type Person = { id: number; @@ -62,5 +63,6 @@ const row = new Row({ data="{people}" columns="{columns}" row="{row}" - defaultSortColumn="Id"> + defaultSortColumn="Id" + headerBackground="{headerBackground}"> diff --git a/packages/frontend/src/lib/table/application/ColumnActions.svelte b/packages/frontend/src/lib/table/application/ColumnActions.svelte new file mode 100644 index 000000000..b0b92d43f --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnActions.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/frontend/src/lib/table/application/ColumnAge.svelte b/packages/frontend/src/lib/table/application/ColumnAge.svelte new file mode 100644 index 000000000..9e27a8e91 --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnAge.svelte @@ -0,0 +1,12 @@ + + +
    + {humanizeAge(moment(object.appState.pod.Created).unix())} +
    diff --git a/packages/frontend/src/lib/table/application/ColumnModel.spec.ts b/packages/frontend/src/lib/table/application/ColumnModel.spec.ts new file mode 100644 index 000000000..9d790acef --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnModel.spec.ts @@ -0,0 +1,96 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import * as catalogStore from '/@/stores/catalog'; +import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog'; +import { readable } from 'svelte/store'; +import type { ApplicationCell } from '../../../pages/applications'; +import ColumnModel from './ColumnModel.svelte'; + +vi.mock('/@/stores/catalog', async () => { + return { + catalog: vi.fn(), + }; +}); + +const initialCatalog: ApplicationCatalog = { + categories: [], + models: [ + { + id: 'model1', + name: 'Model 1', + description: '', + hw: '', + registry: '', + license: '', + url: '', + }, + { + id: 'model2', + name: 'Model 2', + description: '', + hw: '', + registry: '', + license: '', + url: '', + }, + ], + recipes: [], +}; + +test('display model name', async () => { + const obj = { + modelId: 'model1', + } as unknown as ApplicationCell; + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(ColumnModel, { object: obj }); + + const text = screen.getByText('Model 1'); + expect(text).toBeInTheDocument(); +}); + +test('display model port', async () => { + const obj = { + modelId: 'model1', + modelPorts: [8080], + } as unknown as ApplicationCell; + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(ColumnModel, { object: obj }); + + const text = screen.getByText('Model 1'); + expect(text).toBeInTheDocument(); + const ports = screen.getByText('PORT 8080'); + expect(ports).toBeInTheDocument(); +}); + +test('display multpile model ports', async () => { + const obj = { + modelId: 'model1', + modelPorts: [8080, 5000], + } as unknown as ApplicationCell; + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(ColumnModel, { object: obj }); + + const text = screen.getByText('Model 1'); + expect(text).toBeInTheDocument(); + const ports = screen.getByText('PORTS 8080, 5000'); + expect(ports).toBeInTheDocument(); +}); diff --git a/packages/frontend/src/lib/table/application/ColumnModel.svelte b/packages/frontend/src/lib/table/application/ColumnModel.svelte new file mode 100644 index 000000000..5d1b2df90 --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnModel.svelte @@ -0,0 +1,18 @@ + + +
    +
    + {name} +
    +
    + {displayPorts(object.modelPorts)} +
    +
    diff --git a/packages/frontend/src/lib/table/application/ColumnPod.svelte b/packages/frontend/src/lib/table/application/ColumnPod.svelte new file mode 100644 index 000000000..8c3809119 --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnPod.svelte @@ -0,0 +1,9 @@ + + +
    + {object.appState.pod.Name} +
    diff --git a/packages/frontend/src/lib/table/application/ColumnRecipe.spec.ts b/packages/frontend/src/lib/table/application/ColumnRecipe.spec.ts new file mode 100644 index 000000000..c091ed4b7 --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnRecipe.spec.ts @@ -0,0 +1,113 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import * as catalogStore from '/@/stores/catalog'; +import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog'; +import { readable } from 'svelte/store'; +import type { ApplicationCell } from '../../../pages/applications'; +import ColumnRecipe from './ColumnRecipe.svelte'; + +const mocks = vi.hoisted(() => { + return { + openURL: vi.fn(), + }; +}); + +vi.mock('/@/stores/catalog', async () => { + return { + catalog: vi.fn(), + }; +}); + +vi.mock('/@/utils/client', async () => { + return { + studioClient: { + openURL: mocks.openURL, + }, + }; +}); + +const initialCatalog: ApplicationCatalog = { + categories: [], + models: [], + recipes: [ + { + id: 'recipe 1', + name: 'Recipe 1', + readme: 'readme 1', + categories: [], + models: ['model1', 'model2'], + description: 'description 1', + repository: 'repo 1', + }, + { + id: 'recipe 2', + name: 'Recipe 2', + readme: 'readme 2', + categories: [], + description: 'description 2', + repository: 'repo 2', + }, + ], +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('display recipe name', async () => { + const obj = { + recipeId: 'recipe 1', + } as unknown as ApplicationCell; + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(ColumnRecipe, { object: obj }); + + const text = screen.getByText('Recipe 1'); + expect(text).toBeInTheDocument(); +}); + +test('display recipe port', async () => { + const obj = { + recipeId: 'recipe 1', + appPorts: [3000], + } as unknown as ApplicationCell; + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(ColumnRecipe, { object: obj }); + + const text = screen.getByText('Recipe 1'); + expect(text).toBeInTheDocument(); + const ports = screen.getByText('PORT 3000'); + expect(ports).toBeInTheDocument(); +}); + +test('display multiple recipe ports', async () => { + const obj = { + recipeId: 'recipe 1', + appPorts: [3000, 5000], + } as unknown as ApplicationCell; + vi.mocked(catalogStore).catalog = readable(initialCatalog); + render(ColumnRecipe, { object: obj }); + + const text = screen.getByText('Recipe 1'); + expect(text).toBeInTheDocument(); + const ports = screen.getByText('PORTS 3000, 5000'); + expect(ports).toBeInTheDocument(); +}); diff --git a/packages/frontend/src/lib/table/application/ColumnRecipe.svelte b/packages/frontend/src/lib/table/application/ColumnRecipe.svelte new file mode 100644 index 000000000..fd095a7b7 --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnRecipe.svelte @@ -0,0 +1,27 @@ + + +
    +
    + {name} +
    +
    + {displayPorts(object.appPorts)} +
    +
    diff --git a/packages/frontend/src/lib/table/application/ColumnStatus.spec.ts b/packages/frontend/src/lib/table/application/ColumnStatus.spec.ts new file mode 100644 index 000000000..76f01bf24 --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnStatus.spec.ts @@ -0,0 +1,65 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import type { ApplicationCell } from '../../../pages/applications'; +import ColumnStatus from './ColumnStatus.svelte'; + +test('display Pod Running when no task', async () => { + const obj = { + recipeId: 'recipe 1', + appState: { + pod: { + Id: 'pod-1', + }, + }, + } as ApplicationCell; + render(ColumnStatus, { object: obj }); + + const text = screen.getByText('Pod running'); + expect(text).toBeInTheDocument(); +}); + +test('display latest task', async () => { + const obj = { + recipeId: 'recipe 1', + appState: { + pod: { + Id: 'pod-1', + }, + }, + tasks: [ + { + id: 'task1', + name: 'task 1 done', + state: 'success', + }, + { + id: 'task2', + name: 'task 2 running', + state: 'loading', + }, + ], + } as ApplicationCell; + render(ColumnStatus, { object: obj }); + + const text = screen.getByText('task 2 running'); + expect(text).toBeInTheDocument(); +}); diff --git a/packages/frontend/src/lib/table/application/ColumnStatus.svelte b/packages/frontend/src/lib/table/application/ColumnStatus.svelte new file mode 100644 index 000000000..8ba186f28 --- /dev/null +++ b/packages/frontend/src/lib/table/application/ColumnStatus.svelte @@ -0,0 +1,21 @@ + + +
    + {#if task} + + {:else if !!object.appState.pod} + Pod running + {/if} +
    diff --git a/packages/frontend/src/lib/table/model/ModelColumnAction.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnAction.spec.ts new file mode 100644 index 000000000..ec3fcd769 --- /dev/null +++ b/packages/frontend/src/lib/table/model/ModelColumnAction.spec.ts @@ -0,0 +1,158 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import ModelColumnActions from '/@/lib/table/model/ModelColumnActions.svelte'; +import { router } from 'tinro'; + +const mocks = vi.hoisted(() => ({ + requestRemoveLocalModel: vi.fn(), + openFile: vi.fn(), + downloadModel: vi.fn(), +})); + +vi.mock('/@/utils/client', () => ({ + studioClient: { + requestRemoveLocalModel: mocks.requestRemoveLocalModel, + openFile: mocks.openFile, + downloadModel: mocks.downloadModel, + }, +})); + +beforeEach(() => { + vi.resetAllMocks(); + + mocks.downloadModel.mockResolvedValue(undefined); + mocks.openFile.mockResolvedValue(undefined); + mocks.requestRemoveLocalModel.mockResolvedValue(undefined); +}); + +test('Expect folder and delete button in document', async () => { + const d = new Date(); + d.setDate(d.getDate() - 2); + + const object: ModelInfo = { + id: 'my-model', + description: '', + hw: '', + license: '', + name: '', + registry: '', + url: '', + file: { + file: 'file', + creation: d, + size: 1000, + path: 'path', + }, + }; + render(ModelColumnActions, { object }); + + const explorerBtn = screen.getByTitle('Open Model Folder'); + expect(explorerBtn).toBeInTheDocument(); + + const deleteBtn = screen.getByTitle('Delete Model'); + expect(deleteBtn).toBeInTheDocument(); + + const rocketBtn = screen.getByTitle('Create Model Service'); + expect(rocketBtn).toBeInTheDocument(); + + const downloadBtn = screen.queryByTitle('Download Model'); + expect(downloadBtn).toBeNull(); +}); + +test('Expect download button in document', async () => { + const object: ModelInfo = { + id: 'my-model', + description: '', + hw: '', + license: '', + name: '', + registry: '', + url: '', + file: undefined, + }; + render(ModelColumnActions, { object }); + + const explorerBtn = screen.queryByTitle('Open Model Folder'); + expect(explorerBtn).toBeNull(); + + const deleteBtn = screen.queryByTitle('Delete Model'); + expect(deleteBtn).toBeNull(); + + const rocketBtn = screen.queryByTitle('Create Model Service'); + expect(rocketBtn).toBeNull(); + + const downloadBtn = screen.getByTitle('Download Model'); + expect(downloadBtn).toBeInTheDocument(); +}); + +test('Expect downloadModel to be call on click', async () => { + const object: ModelInfo = { + id: 'my-model', + description: '', + hw: '', + license: '', + name: '', + registry: '', + url: '', + file: undefined, + }; + render(ModelColumnActions, { object }); + + const downloadBtn = screen.getByTitle('Download Model'); + expect(downloadBtn).toBeInTheDocument(); + + await fireEvent.click(downloadBtn); + await waitFor(() => { + expect(mocks.downloadModel).toHaveBeenCalledWith('my-model'); + }); +}); + +test('Expect router to be called when rocket icon clicked', async () => { + const gotoMock = vi.spyOn(router, 'goto'); + const replaceMock = vi.spyOn(router.location.query, 'replace'); + + const object: ModelInfo = { + id: 'my-model', + description: '', + hw: '', + license: '', + name: '', + registry: '', + url: '', + file: { + file: 'file', + creation: new Date(), + size: 1000, + path: 'path', + }, + }; + render(ModelColumnActions, { object }); + + const rocketBtn = screen.getByTitle('Create Model Service'); + + await fireEvent.click(rocketBtn); + await waitFor(() => { + expect(gotoMock).toHaveBeenCalledWith('/service/create'); + expect(replaceMock).toHaveBeenCalledWith({ 'model-id': 'my-model' }); + }); +}); diff --git a/packages/frontend/src/lib/table/model/ModelColumnActions.svelte b/packages/frontend/src/lib/table/model/ModelColumnActions.svelte new file mode 100644 index 000000000..be46c46c0 --- /dev/null +++ b/packages/frontend/src/lib/table/model/ModelColumnActions.svelte @@ -0,0 +1,50 @@ + + +{#if object.file !== undefined} + + + +{:else} + +{/if} diff --git a/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts index 5e754ddd8..b8e34c327 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts +++ b/packages/frontend/src/lib/table/model/ModelColumnCreation.spec.ts @@ -32,11 +32,9 @@ test('Expect simple column styling', async () => { hw: '', license: '', name: '', - popularity: 3, registry: '', url: '', file: { - id: 'my-model', file: 'file', creation: d, size: 1000, diff --git a/packages/frontend/src/lib/table/model/ModelColumnCreation.svelte b/packages/frontend/src/lib/table/model/ModelColumnCreation.svelte index 12ffbc59a..8ea826801 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnCreation.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnCreation.svelte @@ -1,11 +1,11 @@
    - {#if (object.file?.creation)} - {humanizeAge(object.file.creation.getTime()/1000)} + {#if object.file?.creation} + {humanizeAge(object.file.creation.getTime() / 1000)} {/if}
    diff --git a/packages/frontend/src/lib/table/model/ModelColumnHW.svelte b/packages/frontend/src/lib/table/model/ModelColumnHW.svelte index 56033b778..716aa3ecc 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnHW.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnHW.svelte @@ -1,6 +1,6 @@
    diff --git a/packages/frontend/src/lib/table/model/ModelColumnLicense.svelte b/packages/frontend/src/lib/table/model/ModelColumnLicense.svelte index f326aa3b2..d5df10d1e 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnLicense.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnLicense.svelte @@ -1,6 +1,6 @@
    diff --git a/packages/frontend/src/lib/table/model/ModelColumnName.svelte b/packages/frontend/src/lib/table/model/ModelColumnName.svelte index 1fbd6c011..543ac8520 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnName.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnName.svelte @@ -1,13 +1,15 @@ - diff --git a/packages/frontend/src/lib/table/model/ModelColumnPopularity.svelte b/packages/frontend/src/lib/table/model/ModelColumnPopularity.svelte deleted file mode 100644 index f7ecfefa4..000000000 --- a/packages/frontend/src/lib/table/model/ModelColumnPopularity.svelte +++ /dev/null @@ -1,8 +0,0 @@ - - -
    - {object.popularity} -
    diff --git a/packages/frontend/src/lib/table/model/ModelColumnRecipeRecommended.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnRecipeRecommended.spec.ts new file mode 100644 index 000000000..ae34a5a14 --- /dev/null +++ b/packages/frontend/src/lib/table/model/ModelColumnRecipeRecommended.spec.ts @@ -0,0 +1,49 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect } from 'vitest'; +import { screen, render } from '@testing-library/svelte'; +import type { RecipeModelInfo } from '/@/models/RecipeModelInfo'; +import ModelColumnRecipeRecommended from './ModelColumnRecipeRecommended.svelte'; + +test('expect the star icon to be rendered whn recipe is recommended', async () => { + render(ModelColumnRecipeRecommended, { + object: { + id: 'id', + inUse: false, + recommended: true, + } as RecipeModelInfo, + }); + + const starIcon = screen.getByTitle('Recommended model'); + expect(starIcon).toBeInTheDocument(); +}); + +test('expect nothing when recipe is NOT recommended', async () => { + render(ModelColumnRecipeRecommended, { + object: { + id: 'id', + inUse: false, + recommended: false, + } as RecipeModelInfo, + }); + + const starIcon = screen.queryByTitle('Recommended model'); + expect(starIcon).not.toBeInTheDocument(); +}); diff --git a/packages/frontend/src/lib/table/model/ModelColumnRecipeRecommended.svelte b/packages/frontend/src/lib/table/model/ModelColumnRecipeRecommended.svelte new file mode 100644 index 000000000..647123751 --- /dev/null +++ b/packages/frontend/src/lib/table/model/ModelColumnRecipeRecommended.svelte @@ -0,0 +1,8 @@ + + +{#if object.recommended} + +{/if} diff --git a/packages/frontend/src/lib/table/model/ModelColumnRecipeSelection.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnRecipeSelection.spec.ts new file mode 100644 index 000000000..643be3374 --- /dev/null +++ b/packages/frontend/src/lib/table/model/ModelColumnRecipeSelection.spec.ts @@ -0,0 +1,42 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { vi, test, expect } from 'vitest'; +import { screen, render, fireEvent } from '@testing-library/svelte'; +import type { RecipeModelInfo } from '/@/models/RecipeModelInfo'; +import ModelColumnRecipeSelection from './ModelColumnRecipeSelection.svelte'; + +const updateMock = vi.fn(); + +test('expect the setSelectedModel is called when swap button is clicked', async () => { + const { component } = render(ModelColumnRecipeSelection, { + object: { + id: 'id', + inUse: false, + } as RecipeModelInfo, + }); + component.$on('update', updateMock); + + const radioSwapModel = screen.getByRole('radio', { name: 'Use this model when running the application' }); + + expect(radioSwapModel).toBeDefined(); + await fireEvent.click(radioSwapModel); + + expect(updateMock).toHaveBeenCalled(); +}); diff --git a/packages/frontend/src/lib/table/model/ModelColumnRecipeSelection.svelte b/packages/frontend/src/lib/table/model/ModelColumnRecipeSelection.svelte new file mode 100644 index 000000000..0fface2ce --- /dev/null +++ b/packages/frontend/src/lib/table/model/ModelColumnRecipeSelection.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/frontend/src/lib/table/model/ModelColumnRegistry.svelte b/packages/frontend/src/lib/table/model/ModelColumnRegistry.svelte index 0786bd415..158a6725e 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnRegistry.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnRegistry.svelte @@ -1,6 +1,6 @@
    diff --git a/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts b/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts index c5825769b..19301285b 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts +++ b/packages/frontend/src/lib/table/model/ModelColumnSize.spec.ts @@ -29,11 +29,9 @@ test('Expect simple column styling', async () => { hw: '', license: '', name: '', - popularity: 3, registry: '', url: '', file: { - id: 'my-model', file: 'file', creation: new Date(), size: 1000, diff --git a/packages/frontend/src/lib/table/model/ModelColumnSize.svelte b/packages/frontend/src/lib/table/model/ModelColumnSize.svelte index 1453ae595..9d978367e 100644 --- a/packages/frontend/src/lib/table/model/ModelColumnSize.svelte +++ b/packages/frontend/src/lib/table/model/ModelColumnSize.svelte @@ -1,11 +1,11 @@
    - {#if (object.file?.size)} + {#if object.file?.size} {filesize(object.file.size)} {/if}
    diff --git a/packages/frontend/src/lib/table/playground/ConversationColumnAction.spec.ts b/packages/frontend/src/lib/table/playground/ConversationColumnAction.spec.ts new file mode 100644 index 000000000..a5ea3f047 --- /dev/null +++ b/packages/frontend/src/lib/table/playground/ConversationColumnAction.spec.ts @@ -0,0 +1,47 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { studioClient } from '/@/utils/client'; +import ConversationColumnAction from '/@/lib/table/playground/ConversationColumnAction.svelte'; + +vi.mock('../../../utils/client', async () => ({ + studioClient: { + requestDeleteConversation: vi.fn(), + }, +})); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(studioClient.requestDeleteConversation).mockResolvedValue(undefined); +}); + +test('should call requestDeleteConversation when click delete', async () => { + render(ConversationColumnAction, { + object: { + id: 'dummyConversationId', + name: 'dummyName', + modelId: 'dummyModelId', + }, + }); + + const startBtn = screen.getByTitle('Delete conversation'); + await fireEvent.click(startBtn); + expect(studioClient.requestDeleteConversation).toHaveBeenCalledWith('dummyConversationId'); +}); diff --git a/packages/frontend/src/lib/table/playground/ConversationColumnAction.svelte b/packages/frontend/src/lib/table/playground/ConversationColumnAction.svelte new file mode 100644 index 000000000..7e8733337 --- /dev/null +++ b/packages/frontend/src/lib/table/playground/ConversationColumnAction.svelte @@ -0,0 +1,15 @@ + + + diff --git a/packages/frontend/src/lib/table/playground/PlaygroundColumnModel.svelte b/packages/frontend/src/lib/table/playground/PlaygroundColumnModel.svelte new file mode 100644 index 000000000..1cb1eb849 --- /dev/null +++ b/packages/frontend/src/lib/table/playground/PlaygroundColumnModel.svelte @@ -0,0 +1,11 @@ + + +
    + {name} +
    diff --git a/packages/frontend/src/lib/table/playground/PlaygroundColumnName.svelte b/packages/frontend/src/lib/table/playground/PlaygroundColumnName.svelte new file mode 100644 index 000000000..7a5f31691 --- /dev/null +++ b/packages/frontend/src/lib/table/playground/PlaygroundColumnName.svelte @@ -0,0 +1,15 @@ + + + diff --git a/packages/frontend/src/lib/table/service/ServiceAction.spec.ts b/packages/frontend/src/lib/table/service/ServiceAction.spec.ts new file mode 100644 index 000000000..16a4ec1cf --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceAction.spec.ts @@ -0,0 +1,115 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import ServiceAction from './ServiceAction.svelte'; +import { studioClient } from '/@/utils/client'; + +vi.mock('../../../utils/client', async () => ({ + studioClient: { + startInferenceServer: vi.fn(), + stopInferenceServer: vi.fn(), + requestDeleteInferenceServer: vi.fn(), + }, +})); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(studioClient.startInferenceServer).mockResolvedValue(undefined); + vi.mocked(studioClient.stopInferenceServer).mockResolvedValue(undefined); + vi.mocked(studioClient.requestDeleteInferenceServer).mockResolvedValue(undefined); +}); + +test('should display stop button when status running', async () => { + render(ServiceAction, { + object: { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const stopBtn = screen.getByTitle('Stop service'); + expect(stopBtn).toBeDefined(); +}); + +test('should display start button when status stopped', async () => { + render(ServiceAction, { + object: { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'stopped', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const startBtn = screen.getByTitle('Start service'); + expect(startBtn).toBeDefined(); +}); + +test('should call stopInferenceServer when click stop', async () => { + render(ServiceAction, { + object: { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const stopBtn = screen.getByTitle('Stop service'); + await fireEvent.click(stopBtn); + expect(studioClient.stopInferenceServer).toHaveBeenCalledWith('dummyContainerId'); +}); + +test('should call startInferenceServer when click start', async () => { + render(ServiceAction, { + object: { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'stopped', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const startBtn = screen.getByTitle('Start service'); + await fireEvent.click(startBtn); + expect(studioClient.startInferenceServer).toHaveBeenCalledWith('dummyContainerId'); +}); + +test('should call deleteInferenceServer when click delete', async () => { + render(ServiceAction, { + object: { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'stopped', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const startBtn = screen.getByTitle('Delete service'); + await fireEvent.click(startBtn); + expect(studioClient.requestDeleteInferenceServer).toHaveBeenCalledWith('dummyContainerId'); +}); diff --git a/packages/frontend/src/lib/table/service/ServiceAction.svelte b/packages/frontend/src/lib/table/service/ServiceAction.svelte new file mode 100644 index 000000000..ed29687d8 --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceAction.svelte @@ -0,0 +1,32 @@ + + +{#if object.status === 'running'} + +{:else} + +{/if} + diff --git a/packages/frontend/src/lib/table/service/ServiceColumnModelName.spec.ts b/packages/frontend/src/lib/table/service/ServiceColumnModelName.spec.ts new file mode 100644 index 000000000..ae5880103 --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceColumnModelName.spec.ts @@ -0,0 +1,73 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ServiceColumnModelName from '/@/lib/table/service/ServiceColumnModelName.svelte'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('the model name should be displayed', async () => { + render(ServiceColumnModelName, { + object: { + health: undefined, + models: [ + { + name: 'dummyName', + } as unknown as ModelInfo, + ], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const modelName = screen.getByText('dummyName'); + expect(modelName).toBeDefined(); + expect(modelName.localName).toBe('span'); +}); + +test('multiple models name should be displayed as list', async () => { + render(ServiceColumnModelName, { + object: { + health: undefined, + models: [ + { + name: 'dummyName-1', + } as unknown as ModelInfo, + { + name: 'dummyName-2', + } as unknown as ModelInfo, + ], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const model1Name = screen.getByText('dummyName-1'); + expect(model1Name).toBeDefined(); + expect(model1Name.localName).toBe('li'); + + const model2Name = screen.getByText('dummyName-2'); + expect(model2Name).toBeDefined(); + expect(model2Name.localName).toBe('li'); +}); diff --git a/packages/frontend/src/lib/table/service/ServiceColumnModelName.svelte b/packages/frontend/src/lib/table/service/ServiceColumnModelName.svelte new file mode 100644 index 000000000..f9c18d4c1 --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceColumnModelName.svelte @@ -0,0 +1,16 @@ + + +{#if object.models.length === 1} + + {object.models[0].name} + +{:else} +
      + {#each object.models as model} +
    • {model.name}
    • + {/each} +
    +{/if} diff --git a/packages/frontend/src/lib/table/service/ServiceColumnName.spec.ts b/packages/frontend/src/lib/table/service/ServiceColumnName.spec.ts new file mode 100644 index 000000000..576addb4a --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceColumnName.spec.ts @@ -0,0 +1,45 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { router } from 'tinro'; +import ServiceColumnName from '/@/lib/table/service/ServiceColumnName.svelte'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +test('click on name should open details page', async () => { + const gotoMock = vi.spyOn(router, 'goto'); + render(ServiceColumnName, { + object: { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const nameBtn = screen.getByTitle('Open service details'); + expect(nameBtn).toBeDefined(); + await fireEvent.click(nameBtn); + + expect(gotoMock).toHaveBeenCalledWith('/service/dummyContainerId'); +}); diff --git a/packages/frontend/src/lib/table/service/ServiceColumnName.svelte b/packages/frontend/src/lib/table/service/ServiceColumnName.svelte new file mode 100644 index 000000000..36164c59b --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceColumnName.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/frontend/src/lib/table/service/ServiceStatus.spec.ts b/packages/frontend/src/lib/table/service/ServiceStatus.spec.ts new file mode 100644 index 000000000..bf5dbe84b --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceStatus.spec.ts @@ -0,0 +1,91 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import ServiceStatus from './ServiceStatus.svelte'; +import { studioClient } from '/@/utils/client'; + +vi.mock('../../../utils/client', async () => ({ + studioClient: { + navigateToContainer: vi.fn(), + }, +})); + +test('undefined health should display a spinner', async () => { + render(ServiceStatus, { + object: { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const img = screen.getByRole('img'); + expect(img).toBeDefined(); + + const button = screen.queryByRole('button'); + expect(button).toBeNull(); +}); + +test('defined health should not display a spinner', async () => { + render(ServiceStatus, { + object: { + health: { + Status: 'starting', + Log: [], + FailingStreak: 1, + }, + models: [], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + + const img = screen.queryByRole('img'); + expect(img).toBeNull(); + + const button = screen.getByRole('button'); + expect(button).toBeDefined(); +}); + +test('click on status icon should redirect to container', async () => { + render(ServiceStatus, { + object: { + health: { + Status: 'starting', + Log: [], + FailingStreak: 1, + }, + models: [], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + }); + // Get button and click on it + const button = screen.getByRole('button'); + await fireEvent.click(button); + + await waitFor(() => { + expect(studioClient.navigateToContainer).toHaveBeenCalledWith('dummyContainerId'); + }); +}); diff --git a/packages/frontend/src/lib/table/service/ServiceStatus.svelte b/packages/frontend/src/lib/table/service/ServiceStatus.svelte new file mode 100644 index 000000000..924321b3f --- /dev/null +++ b/packages/frontend/src/lib/table/service/ServiceStatus.svelte @@ -0,0 +1,39 @@ + + +{#key object.status} + {#if object.health === undefined && object.status !== 'stopped'} + + {:else} + + {/if} +{/key} diff --git a/packages/frontend/src/lib/table/table.ts b/packages/frontend/src/lib/table/table.ts index 6599bfddb..92a858cec 100644 --- a/packages/frontend/src/lib/table/table.ts +++ b/packages/frontend/src/lib/table/table.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (C) 2023 Red Hat, Inc. + * Copyright (C) 2023-2024 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts index 551f434a4..ee6d67b96 100644 --- a/packages/frontend/src/main.ts +++ b/packages/frontend/src/main.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import App from './App.svelte'; const target = document.getElementById('app'); diff --git a/packages/frontend/src/models/RecipeModelInfo.ts b/packages/frontend/src/models/RecipeModelInfo.ts new file mode 100644 index 000000000..b2e017953 --- /dev/null +++ b/packages/frontend/src/models/RecipeModelInfo.ts @@ -0,0 +1,24 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +export interface RecipeModelInfo extends ModelInfo { + recommended: boolean; + inUse: boolean; +} diff --git a/packages/frontend/src/pages/Applications.svelte b/packages/frontend/src/pages/Applications.svelte new file mode 100644 index 000000000..afbcac508 --- /dev/null +++ b/packages/frontend/src/pages/Applications.svelte @@ -0,0 +1,69 @@ + + + +
    +
    +
    + {#if data.length > 0} +
    + {:else} +
    +
    + There is no AI App running. You may run a new AI App via the Recipes Catalog. +
    +
    + {/if} +
    +
    +
    +
    diff --git a/packages/frontend/src/pages/CreateService.spec.ts b/packages/frontend/src/pages/CreateService.spec.ts new file mode 100644 index 000000000..af805a7b3 --- /dev/null +++ b/packages/frontend/src/pages/CreateService.spec.ts @@ -0,0 +1,173 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { vi, beforeEach, test, expect } from 'vitest'; +import { studioClient } from '/@/utils/client'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import CreateService from '/@/pages/CreateService.svelte'; +import type { Task } from '@shared/src/models/ITask'; + +const mocks = vi.hoisted(() => { + return { + // models store + modelsInfoSubscribeMock: vi.fn(), + modelsInfoQueriesMock: { + subscribe: (f: (msg: any) => void) => { + f(mocks.modelsInfoSubscribeMock()); + return () => {}; + }, + }, + // tasks store + tasksSubscribeMock: vi.fn(), + tasksQueriesMock: { + subscribe: (f: (msg: any) => void) => { + f(mocks.tasksSubscribeMock()); + return () => {}; + }, + }, + }; +}); + +vi.mock('../stores/modelsInfo', async () => { + return { + modelsInfo: mocks.modelsInfoQueriesMock, + }; +}); + +vi.mock('../stores/tasks', async () => { + return { + tasks: mocks.tasksQueriesMock, + }; +}); + +vi.mock('../utils/client', async () => ({ + studioClient: { + requestCreateInferenceServer: vi.fn(), + getHostFreePort: vi.fn(), + }, +})); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.modelsInfoSubscribeMock.mockReturnValue([]); + mocks.tasksSubscribeMock.mockReturnValue([]); + + vi.mocked(studioClient.requestCreateInferenceServer).mockResolvedValue('dummyTrackingId'); + vi.mocked(studioClient.getHostFreePort).mockResolvedValue(8888); +}); + +test('create button should be disabled when no model id provided', async () => { + render(CreateService); + + await vi.waitFor(() => { + const createBtn = screen.getByTitle('Create service'); + expect(createBtn).toBeDefined(); + expect(createBtn.attributes.getNamedItem('disabled')).toBeTruthy(); + }); +}); + +test('expect error message to be displayed when no model locally', async () => { + render(CreateService); + + await vi.waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert).toBeDefined(); + }); +}); + +test('expect error message to be hidden when models locally', () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([{ id: 'random', file: true }]); + render(CreateService); + + const alert = screen.queryByRole('alert'); + expect(alert).toBeNull(); +}); + +test('button click should call createInferenceServer', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([{ id: 'random', file: true }]); + render(CreateService); + + let createBtn: HTMLElement | undefined = undefined; + await vi.waitFor(() => { + createBtn = screen.getByTitle('Create service'); + expect(createBtn).toBeDefined(); + }); + + if (createBtn === undefined) throw new Error('createBtn undefined'); + + await fireEvent.click(createBtn); + expect(vi.mocked(studioClient.requestCreateInferenceServer)).toHaveBeenCalledWith({ + modelsInfo: [{ id: 'random', file: true }], + port: 8888, + }); +}); + +test('tasks progress should not be visible by default', async () => { + render(CreateService); + + const status = screen.queryByRole('status'); + expect(status).toBeNull(); +}); + +test('tasks should be displayed after requestCreateInferenceServer', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([{ id: 'random', file: true }]); + + let listener: ((tasks: Task[]) => void) | undefined; + vi.spyOn(mocks.tasksQueriesMock, 'subscribe').mockImplementation((f: (tasks: Task[]) => void) => { + listener = f; + listener([]); + return () => {}; + }); + + render(CreateService); + + // wait for listener to be defined + await vi.waitFor(() => { + expect(listener).toBeDefined(); + }); + + let createBtn: HTMLElement | undefined = undefined; + await vi.waitFor(() => { + createBtn = screen.getByTitle('Create service'); + expect(createBtn).toBeDefined(); + }); + + if (createBtn === undefined || listener === undefined) throw new Error('properties undefined'); + + await fireEvent.click(createBtn); + + await vi.waitFor(() => { + expect(studioClient.requestCreateInferenceServer).toHaveBeenCalled(); + }); + + listener([ + { + id: 'dummyTaskId', + labels: { + trackingId: 'dummyTrackingId', + }, + name: 'Dummy Task name', + state: 'loading', + }, + ]); + + await vi.waitFor(() => { + const status = screen.getByRole('status'); + expect(status).toBeDefined(); + }); +}); diff --git a/packages/frontend/src/pages/CreateService.svelte b/packages/frontend/src/pages/CreateService.svelte new file mode 100644 index 000000000..0b6328288 --- /dev/null +++ b/packages/frontend/src/pages/CreateService.svelte @@ -0,0 +1,181 @@ + + + + +
    + + {#if trackedTasks.length > 0} +
    + +
    + {/if} + + +
    +
    + + + + {#if localModels.length === 0} +
    + + +
    + {/if} + + + +
    +
    +
    + {#if containerId === undefined} + + {:else} + + {/if} +
    +
    +
    +
    +
    +
    diff --git a/packages/frontend/src/pages/Dashboard.spec.ts b/packages/frontend/src/pages/Dashboard.spec.ts new file mode 100644 index 000000000..5dab1b642 --- /dev/null +++ b/packages/frontend/src/pages/Dashboard.spec.ts @@ -0,0 +1,37 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect, vi } from 'vitest'; +import { screen, render } from '@testing-library/svelte'; +import Dashboard from '/@/pages/Dashboard.svelte'; + +vi.mock('../utils/client', async () => { + return { + studioClient: {}, + }; +}); + +test('ensure dashboard is not empty', async () => { + render(Dashboard); + + const innerContent = screen.getByLabelText('inner-content'); + expect(innerContent).toBeDefined(); + const renderer = innerContent.getElementsByTagName('article'); + expect(renderer.length).toBe(1); +}); diff --git a/packages/frontend/src/pages/Dashboard.svelte b/packages/frontend/src/pages/Dashboard.svelte index c5b1f9733..12029f5c0 100644 --- a/packages/frontend/src/pages/Dashboard.svelte +++ b/packages/frontend/src/pages/Dashboard.svelte @@ -1,10 +1,23 @@ + + +
    -
    +
    +
    diff --git a/packages/frontend/src/pages/Environments.svelte b/packages/frontend/src/pages/Environments.svelte deleted file mode 100644 index 45b02b522..000000000 --- a/packages/frontend/src/pages/Environments.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -
    Env
    diff --git a/packages/frontend/src/pages/InferenceServerDetails.spec.ts b/packages/frontend/src/pages/InferenceServerDetails.spec.ts new file mode 100644 index 000000000..4983f4e33 --- /dev/null +++ b/packages/frontend/src/pages/InferenceServerDetails.spec.ts @@ -0,0 +1,138 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { vi, test, expect, beforeEach } from 'vitest'; +import { screen, render } from '@testing-library/svelte'; +import type { InferenceServer } from '@shared/src/models/IInference'; +import InferenceServerDetails from '/@/pages/InferenceServerDetails.svelte'; +import type { Language } from 'postman-code-generators'; +import { studioClient } from '/@/utils/client'; + +const mocks = vi.hoisted(() => { + return { + getInferenceServersMock: vi.fn(), + getSnippetLanguagesMock: vi.fn(), + }; +}); + +vi.mock('../stores/inferenceServers', () => ({ + inferenceServers: { + subscribe: (f: (msg: any) => void) => { + f(mocks.getInferenceServersMock()); + return () => {}; + }, + }, +})); + +vi.mock('../stores/snippetLanguages', () => ({ + snippetLanguages: { + subscribe: (f: (msg: any) => void) => { + f(mocks.getSnippetLanguagesMock()); + return () => {}; + }, + }, +})); + +vi.mock('../utils/client', () => { + return { + studioClient: { + createSnippet: vi.fn(), + }, + }; +}); + +beforeEach(() => { + vi.resetAllMocks(); + + mocks.getSnippetLanguagesMock.mockReturnValue([ + { + key: 'dummyLanguageKey', + label: 'dummyLanguageLabel', + syntax_mode: 'dummySynthaxMode', + variants: [ + { + key: 'dummyLanguageVariant1', + }, + { + key: 'dummyLanguageVariant2', + }, + ], + }, + { + key: 'curl', + label: 'cURL', + syntax_mode: '?', + variants: [ + { + key: 'cURL', + }, + ], + }, + ] as Language[]); + + mocks.getInferenceServersMock.mockReturnValue([ + { + health: undefined, + models: [], + connection: { port: 9999 }, + status: 'running', + container: { + containerId: 'dummyContainerId', + engineId: 'dummyEngineId', + }, + } as InferenceServer, + ]); +}); + +test('ensure address is displayed', async () => { + render(InferenceServerDetails, { + containerId: 'dummyContainerId', + }); + + const address = screen.getByText('http://localhost:9999/v1'); + expect(address).toBeDefined(); +}); + +test('language select must have the mocked snippet languages', async () => { + render(InferenceServerDetails, { + containerId: 'dummyContainerId', + }); + + const select: HTMLSelectElement = screen.getByLabelText('snippet language selection'); + expect(select).toBeDefined(); + expect(select.options.length).toBe(2); + expect(select.options[0].value).toBe('dummyLanguageKey'); +}); + +test('default render should show curl', async () => { + render(InferenceServerDetails, { + containerId: 'dummyContainerId', + }); + + const variantSelect: HTMLSelectElement = screen.getByLabelText('snippet language variant'); + expect(variantSelect.value).toBe('cURL'); +}); + +test('on mount should call createSnippet', async () => { + render(InferenceServerDetails, { + containerId: 'dummyContainerId', + }); + + expect(studioClient.createSnippet).toHaveBeenCalled(); +}); diff --git a/packages/frontend/src/pages/InferenceServerDetails.svelte b/packages/frontend/src/pages/InferenceServerDetails.svelte new file mode 100644 index 000000000..f1390bc59 --- /dev/null +++ b/packages/frontend/src/pages/InferenceServerDetails.svelte @@ -0,0 +1,185 @@ + + + + +
    +
    +
    + {#if service !== undefined} + +
    + + Container +
    +
    + +
    + {service.container.containerId} +
    +
    + +
    + + +
    + Models +
    + {#each service.models as model} +
    +
    {model.name}
    +
    +
    + + {model.license} +
    +
    +
    +
    + + {model.registry} +
    +
    +
    + {/each} +
    +
    +
    + + +
    + Server +
    +
    + http://localhost:{service.connection.port}/v1 +
    + +
    + CPU Inference + +
    +
    +
    + + +
    +
    + Client code + + + + {#if selectedVariant !== undefined} + + {/if} +
    + + {#if snippet !== undefined} +
    + + {snippet} + +
    + {/if} +
    + {/if} +
    +
    +
    +
    +
    diff --git a/packages/frontend/src/pages/InferenceServers.spec.ts b/packages/frontend/src/pages/InferenceServers.spec.ts new file mode 100644 index 000000000..89341fee6 --- /dev/null +++ b/packages/frontend/src/pages/InferenceServers.spec.ts @@ -0,0 +1,75 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { vi, test, expect, beforeEach } from 'vitest'; +import { screen, render } from '@testing-library/svelte'; +import InferenceServers from '/@/pages/InferenceServers.svelte'; +import type { InferenceServer } from '@shared/src/models/IInference'; + +const mocks = vi.hoisted(() => ({ + inferenceServersSubscribeMock: vi.fn(), + inferenceServersMock: { + subscribe: (f: (msg: any) => void) => { + f(mocks.inferenceServersSubscribeMock()); + return () => {}; + }, + }, +})); +vi.mock('../stores/inferenceServers', async () => { + return { + inferenceServers: mocks.inferenceServersMock, + }; +}); + +vi.mock('../utils/client', async () => ({ + studioClient: { + getInferenceServers: vi.fn(), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.inferenceServersSubscribeMock.mockReturnValue([]); +}); + +test('no inference servers should display a status message', async () => { + render(InferenceServers); + const status = screen.getByRole('status'); + expect(status).toBeInTheDocument(); + expect(status.textContent).toBe('There is no services running for now.'); + + const table = screen.queryByRole('table'); + expect(table).toBeNull(); +}); + +test('store with inference server should display the table', async () => { + mocks.inferenceServersSubscribeMock.mockReturnValue([ + { + health: undefined, + models: [], + connection: { port: 8888 }, + status: 'running', + container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' }, + }, + ] as InferenceServer[]); + render(InferenceServers); + + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); +}); diff --git a/packages/frontend/src/pages/InferenceServers.svelte b/packages/frontend/src/pages/InferenceServers.svelte new file mode 100644 index 000000000..4c251316a --- /dev/null +++ b/packages/frontend/src/pages/InferenceServers.svelte @@ -0,0 +1,38 @@ + + + + +
    +
    +
    + {#if data.length > 0} +
    + {:else} +
    There is no services running for now.
    + {/if} +
    +
    +
    +
    +
    diff --git a/packages/frontend/src/pages/Model.spec.ts b/packages/frontend/src/pages/Model.spec.ts index 74ee77084..3c784f5ea 100644 --- a/packages/frontend/src/pages/Model.spec.ts +++ b/packages/frontend/src/pages/Model.spec.ts @@ -1,7 +1,25 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import { vi, test, expect } from 'vitest'; import { screen, render } from '@testing-library/svelte'; import Model from './Model.svelte'; -import catalog from '../../../backend/src/ai-user-test.json'; +import catalog from '../../../backend/src/tests/ai-user-test.json'; const mocks = vi.hoisted(() => { return { diff --git a/packages/frontend/src/pages/Model.svelte b/packages/frontend/src/pages/Model.svelte index c9581c194..5a27977f2 100644 --- a/packages/frontend/src/pages/Model.svelte +++ b/packages/frontend/src/pages/Model.svelte @@ -1,9 +1,7 @@ - - - - - -
    -
    - -
    +
    +
    +
    - - - - - +
    diff --git a/packages/frontend/src/pages/ModelPlayground.spec.ts b/packages/frontend/src/pages/ModelPlayground.spec.ts deleted file mode 100644 index 14617bd68..000000000 --- a/packages/frontend/src/pages/ModelPlayground.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { vi, test, expect, beforeEach } from 'vitest'; -import { screen, fireEvent, render, waitFor } from '@testing-library/svelte'; -import ModelPlayground from './ModelPlayground.svelte'; -import type { ModelInfo } from '@shared/src/models/IModelInfo'; - -const mocks = vi.hoisted(() => { - return { - startPlaygroundMock: vi.fn(), - askPlaygroundMock: vi.fn(), - getPlaygroundsStateMock: vi.fn().mockImplementation(() => Promise.resolve([])), - playgroundQueriesSubscribeMock: vi.fn(), - playgroundQueriesMock: { - subscribe: (f: (msg: any) => void) => { - f(mocks.playgroundQueriesSubscribeMock()); - return () => {}; - }, - }, - }; -}); - -vi.mock('../utils/client', async () => { - return { - studioClient: { - getPlaygroundsState: mocks.getPlaygroundsStateMock, - startPlayground: mocks.startPlaygroundMock, - askPlayground: mocks.askPlaygroundMock, - askPlaygroundQueries: () => {}, - }, - rpcBrowser: { - subscribe: () => { - return { - unsubscribe: () => {}, - }; - }, - }, - }; -}); - -vi.mock('../stores/playground-queries', async () => { - return { - playgroundQueries: mocks.playgroundQueriesMock, - }; -}); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -test('playground should start when clicking on the play button', async () => { - mocks.playgroundQueriesSubscribeMock.mockReturnValue([]); - render(ModelPlayground, { - model: { - id: 'model1', - name: 'Model 1', - description: 'A description', - hw: 'CPU', - registry: 'Hugging Face', - popularity: 3, - license: '?', - url: 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf', - } as ModelInfo, - }); - - const play = screen.getByTitle('playground-action'); - expect(play).toBeDefined(); - - await fireEvent.click(play); - - await waitFor(() => { - expect(mocks.startPlaygroundMock).toHaveBeenCalledOnce(); - }); -}); - -test('should display query without response', async () => { - mocks.playgroundQueriesSubscribeMock.mockReturnValue([ - { - id: 1, - modelId: 'model1', - prompt: 'what is 1+1?', - }, - ]); - render(ModelPlayground, { - model: { - id: 'model1', - name: 'Model 1', - description: 'A description', - hw: 'CPU', - registry: 'Hugging Face', - popularity: 3, - license: '?', - url: 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf', - } as ModelInfo, - }); - await waitFor(() => { - const prompt = screen.getByPlaceholderText('Type your prompt here'); - expect(prompt).toBeInTheDocument(); - expect(prompt).toHaveValue('what is 1+1?'); - }); - - const response = screen.queryByRole('textbox', { name: 'response' }); - expect(response).not.toBeInTheDocument(); -}); - -test('should display query without response', async () => { - mocks.playgroundQueriesSubscribeMock.mockReturnValue([ - { - id: 1, - modelId: 'model1', - prompt: 'what is 1+1?', - response: { - choices: [ - { - text: 'The response is 2', - }, - ], - }, - }, - ]); - render(ModelPlayground, { - model: { - id: 'model1', - name: 'Model 1', - description: 'A description', - hw: 'CPU', - registry: 'Hugging Face', - popularity: 3, - license: '?', - url: 'https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q5_K_S.gguf', - } as ModelInfo, - }); - - await waitFor(() => { - const prompt = screen.getByPlaceholderText('Type your prompt here'); - expect(prompt).toBeInTheDocument(); - expect(prompt).toHaveValue('what is 1+1?'); - }); - - const response = screen.queryByRole('textbox', { name: 'response' }); - expect(response).toBeInTheDocument(); - expect(response).toHaveValue('The response is 2'); -}); diff --git a/packages/frontend/src/pages/ModelPlayground.svelte b/packages/frontend/src/pages/ModelPlayground.svelte deleted file mode 100644 index 7394256b9..000000000 --- a/packages/frontend/src/pages/ModelPlayground.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - -
    - -
    - {#key playgroundState?.status} - Playground {playgroundState?.status} -
    -
    -
    Prompt
    - - -
    - {#key playgroundState?.status} - - {/key} -
    - - {#if result} -
    Output
    - - {/if} -
    - diff --git a/packages/frontend/src/pages/Models.spec.ts b/packages/frontend/src/pages/Models.spec.ts new file mode 100644 index 000000000..c414a7a12 --- /dev/null +++ b/packages/frontend/src/pages/Models.spec.ts @@ -0,0 +1,228 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { vi, test, expect } from 'vitest'; +import { screen, render, waitFor } from '@testing-library/svelte'; +import Models from './Models.svelte'; +import { router } from 'tinro'; + +const mocks = vi.hoisted(() => { + return { + getCatalogMock: vi.fn(), + getPullingStatusesMock: vi.fn().mockResolvedValue(new Map()), + modelsInfoSubscribeMock: vi.fn(), + tasksSubscribeMock: vi.fn(), + modelsInfoQueriesMock: { + subscribe: (f: (msg: any) => void) => { + f(mocks.modelsInfoSubscribeMock()); + return () => {}; + }, + }, + tasksQueriesMock: { + subscribe: (f: (msg: any) => void) => { + f(mocks.tasksSubscribeMock()); + return () => {}; + }, + }, + getModelsInfoMock: vi.fn().mockResolvedValue([]), + getTasks: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('/@/utils/client', async () => { + return { + studioClient: { + getModelsInfo: mocks.getModelsInfoMock, + getPullingStatuses: mocks.getPullingStatusesMock, + }, + rpcBrowser: { + subscribe: () => { + return { + unsubscribe: () => {}, + }; + }, + }, + }; +}); + +vi.mock('../stores/modelsInfo', async () => { + return { + modelsInfo: mocks.modelsInfoQueriesMock, + }; +}); + +vi.mock('../stores/tasks', async () => { + return { + tasks: mocks.tasksQueriesMock, + }; +}); + +test('should display There is no model yet', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([]); + mocks.tasksSubscribeMock.mockReturnValue([]); + + render(Models); + + const status = screen.getByRole('status'); + expect(status).toBeDefined(); +}); + +test('should display There is no model yet and have a task running', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([]); + mocks.tasksSubscribeMock.mockReturnValue([ + { + id: 'random', + name: 'random', + state: 'loading', + labels: { + 'model-pulling': 'random-models-id', + }, + }, + ]); + render(Models); + + const status = screen.getByRole('status'); + expect(status).toBeDefined(); + + await waitFor(() => { + const title = screen.getByText('Downloading models'); + expect(title).toBeDefined(); + }); +}); + +test('should not display any tasks running', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([]); + mocks.tasksSubscribeMock.mockReturnValue([ + { + id: 'random', + name: 'random', + state: 'loading', + }, + ]); + mocks.getPullingStatusesMock.mockResolvedValue([]); + + render(Models); + + const notification = screen.queryByText('Downloading models'); + expect(notification).toBeNull(); +}); + +test('should display one model', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([ + { + id: 'dummy-id', + name: 'dummy-name', + }, + ]); + mocks.tasksSubscribeMock.mockReturnValue([]); + + render(Models); + + const table = screen.getByRole('table'); + expect(table).toBeDefined(); + + const cells = screen.queryAllByRole('cell'); + expect(cells.length > 0).toBeTruthy(); + + const name = cells.find(cell => cell.firstElementChild?.textContent === 'dummy-name'); + expect(name).toBeDefined(); +}); + +test('should display no model in downloaded tab', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([ + { + id: 'dummy-id', + name: 'dummy-name', + }, + ]); + mocks.tasksSubscribeMock.mockReturnValue([]); + + render(Models); + + router.goto('downloaded'); + + await waitFor(() => { + const status = screen.getByRole('status'); + expect(status).toBeDefined(); + }); +}); + +test('should display a model in downloaded tab', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([ + { + id: 'dummy-id', + name: 'dummy-name', + file: { + file: 'dummy', + path: 'dummy', + }, + }, + ]); + mocks.tasksSubscribeMock.mockReturnValue([]); + + render(Models); + + router.goto('downloaded'); + + await waitFor(() => { + const table = screen.getByRole('table'); + expect(table).toBeDefined(); + }); +}); + +test('should display a model in available tab', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([ + { + id: 'dummy-id', + name: 'dummy-name', + }, + ]); + mocks.tasksSubscribeMock.mockReturnValue([]); + + render(Models); + + router.goto('available'); + + await waitFor(() => { + const table = screen.getByRole('table'); + expect(table).toBeDefined(); + }); +}); + +test('should display no model in available tab', async () => { + mocks.modelsInfoSubscribeMock.mockReturnValue([ + { + id: 'dummy-id', + name: 'dummy-name', + file: { + file: 'dummy', + path: 'dummy', + }, + }, + ]); + mocks.tasksSubscribeMock.mockReturnValue([]); + + render(Models); + + router.goto('available'); + + await waitFor(() => { + const status = screen.getByRole('status'); + expect(status).toBeDefined(); + }); +}); diff --git a/packages/frontend/src/pages/Models.svelte b/packages/frontend/src/pages/Models.svelte index 4420ec43b..1416f75a5 100644 --- a/packages/frontend/src/pages/Models.svelte +++ b/packages/frontend/src/pages/Models.svelte @@ -3,19 +3,21 @@ import type { ModelInfo } from '@shared/src/models/IModelInfo'; import NavPage from '../lib/NavPage.svelte'; import Table from '../lib/table/Table.svelte'; import { Column, Row } from '../lib/table/table'; -import { localModels } from '../stores/local-models'; +import { modelsInfo } from '../stores/modelsInfo'; import ModelColumnName from '../lib/table/model/ModelColumnName.svelte'; import ModelColumnRegistry from '../lib/table/model/ModelColumnRegistry.svelte'; -import ModelColumnPopularity from '../lib/table/model/ModelColumnPopularity.svelte'; import ModelColumnLicense from '../lib/table/model/ModelColumnLicense.svelte'; import ModelColumnHw from '../lib/table/model/ModelColumnHW.svelte'; import type { Task } from '@shared/src/models/ITask'; import TasksProgress from '/@/lib/progress/TasksProgress.svelte'; import Card from '/@/lib/Card.svelte'; -import { modelsPulling } from '../stores/recipe'; import { onMount } from 'svelte'; import ModelColumnSize from '../lib/table/model/ModelColumnSize.svelte'; - import ModelColumnCreation from '../lib/table/model/ModelColumnCreation.svelte'; +import ModelColumnCreation from '../lib/table/model/ModelColumnCreation.svelte'; +import ModelColumnActions from '../lib/table/model/ModelColumnActions.svelte'; +import Tab from '/@/lib/Tab.svelte'; +import Route from '/@/Route.svelte'; +import { tasks } from '/@/stores/tasks'; const columns: Column[] = [ new Column('Name', { width: '3fr', renderer: ModelColumnName }), @@ -23,79 +25,118 @@ const columns: Column[] = [ new Column('Creation', { width: '1fr', renderer: ModelColumnCreation }), new Column('HW Compat', { width: '1fr', renderer: ModelColumnHw }), new Column('Registry', { width: '2fr', renderer: ModelColumnRegistry }), - new Column('Popularity', { width: '1fr', renderer: ModelColumnPopularity }), new Column('License', { width: '2fr', renderer: ModelColumnLicense }), + new Column('Actions', { align: 'right', width: '120px', renderer: ModelColumnActions }), ]; const row = new Row({}); let loading: boolean = true; -let tasks: Task[] = []; +let pullingTasks: Task[] = []; let models: ModelInfo[] = []; + +// filtered mean, we remove the models that are being downloaded let filteredModels: ModelInfo[] = []; +$: localModels = filteredModels.filter(model => model.file); +$: remoteModels = filteredModels.filter(model => !model.file); + function filterModels(): void { // Let's collect the models we do not want to show (loading, error). - const modelsId: string[] = tasks.reduce((previousValue, currentValue) => { - if(currentValue.state === 'success') - return previousValue; - - if(currentValue.labels !== undefined) { - previousValue.push(currentValue.labels["model-pulling"]); + const modelsId: string[] = pullingTasks.reduce((previousValue, currentValue) => { + if (currentValue.labels !== undefined) { + previousValue.push(currentValue.labels['model-pulling']); } return previousValue; }, [] as string[]); - filteredModels = models.filter((model) => !modelsId.includes(model.id)); + filteredModels = models.filter(model => !modelsId.includes(model.id)); } onMount(() => { - // Pulling update - const modelsPullingUnsubscribe = modelsPulling.subscribe(runningTasks => { - tasks = runningTasks; + // Subscribe to the tasks store + const tasksUnsubscribe = tasks.subscribe(value => { + // Filter out duplicates + const modelIds = new Set(); + pullingTasks = value.reduce((filtered: Task[], task: Task) => { + if ( + task.state === 'loading' && + task.labels !== undefined && + 'model-pulling' in task.labels && + !modelIds.has(task.labels['model-pulling']) + ) { + modelIds.add(task.labels['model-pulling']); + filtered.push(task); + } + return filtered; + }, []); + loading = false; filterModels(); }); // Subscribe to the models store - const localModelsUnsubscribe = localModels.subscribe((value) => { + const localModelsUnsubscribe = modelsInfo.subscribe(value => { models = value; filterModels(); - }) + }); return () => { - modelsPullingUnsubscribe(); + tasksUnsubscribe(); localModelsUnsubscribe(); - } + }; }); - -
    -
    -
    - {#if !loading} - {#if tasks.length > 0} -
    + + + + + + + + +
    +
    +
    + {#if !loading} + {#if pullingTasks.length > 0}
    Downloading models
    - +
    -
    - {/if} - {#if filteredModels.length > 0} - -
    - {:else} -
    There is no model yet
    + {/if} + + + + {#if filteredModels.length > 0} +
    + {:else} +
    There is no model yet
    + {/if} +
    + + + + {#if localModels.length > 0} +
    + {:else} +
    There is no model yet
    + {/if} +
    + + + + {#if remoteModels.length > 0} +
    + {:else} +
    There is no model yet
    + {/if} +
    {/if} - {/if} +
    -
    + diff --git a/packages/frontend/src/pages/Playground.spec.ts b/packages/frontend/src/pages/Playground.spec.ts new file mode 100644 index 000000000..8c8bc7472 --- /dev/null +++ b/packages/frontend/src/pages/Playground.spec.ts @@ -0,0 +1,301 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { render, screen, waitFor, within } from '@testing-library/svelte'; +import { expect, test, vi } from 'vitest'; +import Playground from './Playground.svelte'; +import { studioClient } from '../utils/client'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { fireEvent } from '@testing-library/dom'; +import type { AssistantChat, Conversation, PendingChat, UserChat } from '@shared/src/models/IPlaygroundMessage'; +import * as conversationsStore from '/@/stores/conversations'; +import { writable } from 'svelte/store'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../utils/client', async () => { + return { + studioClient: { + getCatalog: vi.fn(), + getPlaygroundsV2: vi.fn(), + submitPlaygroundMessage: vi.fn(), + }, + rpcBrowser: { + subscribe: () => { + return { + unsubscribe: () => {}, + }; + }, + }, + }; +}); + +vi.mock('/@/stores/conversations', async () => { + return { + conversations: vi.fn(), + }; +}); + +test('should display playground and model names in header', async () => { + vi.mocked(studioClient.getCatalog).mockResolvedValue({ + models: [ + { + id: 'model-1', + name: 'Model 1', + }, + ] as ModelInfo[], + recipes: [], + categories: [], + }); + vi.mocked(studioClient.getPlaygroundsV2).mockResolvedValue([ + { + id: 'playground-1', + name: 'Playground 1', + modelId: 'model-1', + }, + ]); + const customConversations = writable([]); + vi.mocked(conversationsStore).conversations = customConversations; + render(Playground, { + playgroundId: 'playground-1', + }); + + await waitFor(() => { + const header = screen.getByRole('region', { name: 'header' }); + expect(header).toBeInTheDocument(); + const title = within(header).getByText('Playground 1'); + expect(title).toBeInTheDocument(); + const subtitle = within(header).getByText('Model 1'); + expect(subtitle).toBeInTheDocument(); + }); +}); + +test('send prompt should be enabled initially', async () => { + vi.mocked(studioClient.getCatalog).mockResolvedValue({ + models: [ + { + id: 'model-1', + name: 'Model 1', + }, + ] as ModelInfo[], + recipes: [], + categories: [], + }); + vi.mocked(studioClient.getPlaygroundsV2).mockResolvedValue([ + { + id: 'playground-1', + name: 'Playground 1', + modelId: 'model-1', + }, + ]); + const customConversations = writable([]); + vi.mocked(conversationsStore).conversations = customConversations; + render(Playground, { + playgroundId: 'playground-1', + }); + + await waitFor(() => { + const send = screen.getByRole('button', { name: 'Send prompt' }); + expect(send).toBeEnabled(); + }); +}); + +test('sending prompt should disable the send button', async () => { + vi.mocked(studioClient.getCatalog).mockResolvedValue({ + models: [ + { + id: 'model-1', + name: 'Model 1', + }, + ] as ModelInfo[], + recipes: [], + categories: [], + }); + vi.mocked(studioClient.getPlaygroundsV2).mockResolvedValue([ + { + id: 'playground-1', + name: 'Playground 1', + modelId: 'model-1', + }, + ]); + vi.mocked(studioClient.submitPlaygroundMessage).mockResolvedValue(); + const customConversations = writable([]); + vi.mocked(conversationsStore).conversations = customConversations; + render(Playground, { + playgroundId: 'playground-1', + }); + + let send: HTMLElement; + await waitFor(() => { + send = screen.getByRole('button', { name: 'Send prompt' }); + expect(send).toBeInTheDocument(); + }); + fireEvent.click(send!); + + await waitFor(() => { + send = screen.getByRole('button', { name: 'Send prompt' }); + expect(send).toBeDisabled(); + }); +}); + +test('receiving complete message should enable the send button', async () => { + vi.mocked(studioClient.getCatalog).mockResolvedValue({ + models: [ + { + id: 'model-1', + name: 'Model 1', + }, + ] as ModelInfo[], + recipes: [], + categories: [], + }); + vi.mocked(studioClient.getPlaygroundsV2).mockResolvedValue([ + { + id: 'playground-1', + name: 'Playground 1', + modelId: 'model-1', + }, + ]); + const customConversations = writable([]); + vi.mocked(conversationsStore).conversations = customConversations; + render(Playground, { + playgroundId: 'playground-1', + }); + + let send: HTMLElement; + await waitFor(() => { + send = screen.getByRole('button', { name: 'Send prompt' }); + expect(send).toBeInTheDocument(); + }); + fireEvent.click(send!); + + await waitFor(() => { + send = screen.getByRole('button', { name: 'Send prompt' }); + expect(send).toBeDisabled(); + }); + + customConversations.set([ + { + id: 'playground-1', + messages: [ + { + role: 'user', + id: 'message-1', + content: 'a prompt', + } as UserChat, + { + role: 'assistant', + id: 'message-2', + content: 'a response', + completed: Date.now(), + } as AssistantChat, + ], + }, + ]); + + await waitFor(() => { + send = screen.getByRole('button', { name: 'Send prompt' }); + expect(send).toBeEnabled(); + }); +}); + +test('sending prompt should display the prompt and the response', async () => { + vi.mocked(studioClient.getCatalog).mockResolvedValue({ + models: [ + { + id: 'model-1', + name: 'Model 1', + }, + ] as ModelInfo[], + recipes: [], + categories: [], + }); + vi.mocked(studioClient.getPlaygroundsV2).mockResolvedValue([ + { + id: 'playground-1', + name: 'Playground 1', + modelId: 'model-1', + }, + ]); + const customConversations = writable([]); + vi.mocked(conversationsStore).conversations = customConversations; + render(Playground, { + playgroundId: 'playground-1', + }); + + let send: HTMLElement; + await waitFor(() => { + send = screen.getByRole('button', { name: 'Send prompt' }); + expect(send).toBeInTheDocument(); + }); + const textarea = screen.getByLabelText('prompt'); + expect(textarea).toBeInTheDocument(); + await userEvent.type(textarea, 'a question for the assistant'); + + fireEvent.click(send!); + + customConversations.set([ + { + id: 'playground-1', + messages: [ + { + role: 'user', + id: 'message-1', + content: 'a question for the assistant', + } as UserChat, + { + role: 'assistant', + id: 'message-2', + choices: [{ content: 'a ' }, { content: 'response ' }, { content: 'from ' }, { content: 'the ' }], + completed: false, + } as unknown as PendingChat, + ], + }, + ]); + + await waitFor(() => { + const conversation = screen.getByLabelText('conversation'); + within(conversation).getByText('a question for the assistant'); + within(conversation).getByText('a response from the'); + }); + + customConversations.set([ + { + id: 'playground-1', + messages: [ + { + role: 'user', + id: 'message-1', + content: 'a question for the assistant', + } as UserChat, + { + role: 'assistant', + id: 'message-2', + content: 'a response from the assistant', + completed: Date.now(), + } as AssistantChat, + ], + }, + ]); + + await waitFor(() => { + const conversation = screen.getByLabelText('conversation'); + within(conversation).getByText('a question for the assistant'); + within(conversation).getByText('a response from the assistant'); + }); +}); diff --git a/packages/frontend/src/pages/Playground.svelte b/packages/frontend/src/pages/Playground.svelte new file mode 100644 index 000000000..d1edba594 --- /dev/null +++ b/packages/frontend/src/pages/Playground.svelte @@ -0,0 +1,198 @@ + + +{#if playground} + + {model?.name} + +
    +
    + + +
    +
    + {#if conversation?.messages} +
      + {#each conversation?.messages as message} +
    • +
      + {roleNames[message.role]} +
      +
      + {#each getMessageParagraphs(message) as paragraph} +

      {paragraph}

      + {/each} +
      + {#if isAssistantChat(message)} +
      + {elapsedTime(message)} s +
      + {/if} +
      +
    • + {/each} +
    + {/if} +
    +
    +
    + +
    Next prompt will use these settings
    +
    +
    System Prompt
    +
    + +
    +
    +
    +
    Model Parameters
    +
    + + + +
    +
    +
    +
    +
    + {#if errorMsg} +
    {errorMsg}
    + {/if} +
    + + +
    + +
    +
    +
    +
    +
    +{/if} diff --git a/packages/frontend/src/pages/PlaygroundCreate.svelte b/packages/frontend/src/pages/PlaygroundCreate.svelte new file mode 100644 index 000000000..102152d6d --- /dev/null +++ b/packages/frontend/src/pages/PlaygroundCreate.svelte @@ -0,0 +1,180 @@ + + + + +
    + + {#if trackedTasks.length > 0} +
    + +
    + {/if} + + +
    +
    + + + + + + + + {#if localModels.length === 0} +
    + + +
    + {:else if availModels.length > 0} +
    + + +
    + {/if} +
    +
    +
    + +
    +
    +
    +
    +
    +
    diff --git a/packages/frontend/src/pages/Playgrounds.svelte b/packages/frontend/src/pages/Playgrounds.svelte new file mode 100644 index 000000000..1696de78c --- /dev/null +++ b/packages/frontend/src/pages/Playgrounds.svelte @@ -0,0 +1,50 @@ + + + + + + + +
    +
    +
    + {#if $playgrounds.length > 0} +
    + {:else} +
    + There is no playground environment for now. You can create one now. +
    + {/if} +
    +
    +
    +
    +
    diff --git a/packages/frontend/src/pages/Preferences.svelte b/packages/frontend/src/pages/Preferences.svelte index f25a965b8..be07c4a58 100644 --- a/packages/frontend/src/pages/Preferences.svelte +++ b/packages/frontend/src/pages/Preferences.svelte @@ -1,5 +1,4 @@
    Preferences
    diff --git a/packages/frontend/src/pages/Recipe.spec.ts b/packages/frontend/src/pages/Recipe.spec.ts index fdbf2cd3d..ccf9abcc6 100644 --- a/packages/frontend/src/pages/Recipe.spec.ts +++ b/packages/frontend/src/pages/Recipe.spec.ts @@ -1,20 +1,67 @@ -import { vi, test, expect } from 'vitest'; +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { vi, test, expect, beforeEach } from 'vitest'; import { screen, render } from '@testing-library/svelte'; -import catalog from '../../../backend/src/ai-user-test.json'; import Recipe from './Recipe.svelte'; +import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog'; +import * as catalogStore from '/@/stores/catalog'; +import { readable, writable } from 'svelte/store'; const mocks = vi.hoisted(() => { return { getCatalogMock: vi.fn(), getPullingStatusesMock: vi.fn(), + pullApplicationMock: vi.fn(), + telemetryLogUsageMock: vi.fn(), + getApplicationsStateMock: vi.fn(), + getLocalRepositoriesMock: vi.fn(), + getTasksMock: vi.fn(), }; }); +vi.mock('../stores/tasks', () => ({ + tasks: { + subscribe: (f: (msg: any) => void) => { + f(mocks.getTasksMock()); + return () => {}; + }, + }, +})); + +vi.mock('../stores/localRepositories', () => ({ + localRepositories: { + subscribe: (f: (msg: any) => void) => { + f(mocks.getLocalRepositoriesMock()); + return () => {}; + }, + }, +})); + vi.mock('../utils/client', async () => { return { studioClient: { getCatalog: mocks.getCatalogMock, getPullingStatuses: mocks.getPullingStatusesMock, + pullApplication: mocks.pullApplicationMock, + telemetryLogUsage: mocks.telemetryLogUsageMock, + getApplicationsState: mocks.getApplicationsStateMock, }, rpcBrowser: { subscribe: () => { @@ -26,17 +73,146 @@ vi.mock('../utils/client', async () => { }; }); +vi.mock('/@/stores/catalog', async () => { + return { + catalog: vi.fn(), + }; +}); + +const initialCatalog: ApplicationCatalog = { + categories: [], + models: [ + { + id: 'model1', + name: 'Model 1', + description: 'Readme for model 1', + hw: 'CPU', + registry: 'Hugging Face', + license: '?', + url: 'https://model1.example.com', + }, + { + id: 'model2', + name: 'Model 2', + description: 'Readme for model 2', + hw: 'CPU', + registry: 'Civital', + license: '?', + url: '', + }, + ], + recipes: [ + { + id: 'recipe 1', + name: 'Recipe 1', + readme: 'readme 1', + categories: [], + models: ['model1', 'model2'], + description: 'description 1', + repository: 'repo 1', + }, + { + id: 'recipe 2', + name: 'Recipe 2', + readme: 'readme 2', + categories: [], + description: 'description 2', + repository: 'repo 2', + }, + ], +}; + +const updatedCatalog: ApplicationCatalog = { + categories: [], + models: [ + { + id: 'model1', + name: 'Model 1', + description: 'Readme for model 1', + hw: 'CPU', + registry: 'Hugging Face', + license: '?', + url: 'https://model1.example.com', + }, + { + id: 'model2', + name: 'Model 2', + description: 'Readme for model 2', + hw: 'CPU', + registry: 'Civital', + license: '?', + url: '', + }, + ], + recipes: [ + { + id: 'recipe 1', + name: 'New Recipe Name', + readme: 'readme 1', + categories: [], + models: ['model1', 'model2'], + description: 'description 1', + repository: 'repo 1', + }, + { + id: 'recipe 2', + name: 'Recipe 2', + readme: 'readme 2', + categories: [], + description: 'description 2', + repository: 'repo 2', + }, + ], +}; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.getLocalRepositoriesMock.mockReturnValue([]); + mocks.getTasksMock.mockReturnValue([]); +}); + test('should display recipe information', async () => { - const recipe = catalog.recipes.find(r => r.id === 'recipe 1'); - expect(recipe).not.toBeUndefined(); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + mocks.getApplicationsStateMock.mockResolvedValue([]); + mocks.getPullingStatusesMock.mockResolvedValue([]); + render(Recipe, { + recipeId: 'recipe 1', + }); + + screen.getByText('Recipe 1'); + screen.getByText('readme 1'); +}); + +test('should display updated recipe information', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + const customCatalog = writable(initialCatalog); + vi.mocked(catalogStore).catalog = customCatalog; + mocks.getPullingStatusesMock.mockResolvedValue([]); + render(Recipe, { + recipeId: 'recipe 1', + }); + + screen.getByText('Recipe 1'); + screen.getByText('readme 1'); + + customCatalog.set(updatedCatalog); + await new Promise(resolve => setTimeout(resolve, 10)); + screen.getByText('New Recipe Name'); +}); + +test('should send telemetry data', async () => { + mocks.getApplicationsStateMock.mockResolvedValue([]); + vi.mocked(catalogStore).catalog = readable(initialCatalog); + mocks.getPullingStatusesMock.mockResolvedValue([]); + mocks.pullApplicationMock.mockResolvedValue(undefined); - mocks.getCatalogMock.mockResolvedValue(catalog); - mocks.getPullingStatusesMock.mockResolvedValue(new Map()); render(Recipe, { recipeId: 'recipe 1', }); await new Promise(resolve => setTimeout(resolve, 200)); - screen.getByText(recipe!.name); - screen.getByText(recipe!.readme); + expect(mocks.telemetryLogUsageMock).toHaveBeenNthCalledWith(1, 'recipe.open', { + 'recipe.id': 'recipe 1', + 'recipe.name': 'Recipe 1', + }); }); diff --git a/packages/frontend/src/pages/Recipe.svelte b/packages/frontend/src/pages/Recipe.svelte index 3736ed6ff..e93b7a5c4 100644 --- a/packages/frontend/src/pages/Recipe.svelte +++ b/packages/frontend/src/pages/Recipe.svelte @@ -5,102 +5,65 @@ import Tab from '/@/lib/Tab.svelte'; import Route from '/@/Route.svelte'; import Card from '/@/lib/Card.svelte'; import MarkdownRenderer from '/@/lib/markdown/MarkdownRenderer.svelte'; -import Fa from 'svelte-fa'; -import { faGithub } from '@fortawesome/free-brands-svg-icons'; -import { faDownload, faRefresh } from '@fortawesome/free-solid-svg-icons'; -import TasksProgress from '/@/lib/progress/TasksProgress.svelte'; -import Button from '/@/lib/button/Button.svelte'; -import { getDisplayName } from '/@/utils/versionControlUtils'; import { getIcon } from '/@/utils/categoriesUtils'; import RecipeModels from './RecipeModels.svelte'; import { catalog } from '/@/stores/catalog'; - import { recipes } from '/@/stores/recipe'; +import RecipeDetails from '/@/lib/RecipeDetails.svelte'; +import ContentDetailsLayout from '../lib/ContentDetailsLayout.svelte'; export let recipeId: string; // The recipe model provided $: recipe = $catalog.recipes.find(r => r.id === recipeId); $: categories = $catalog.categories; -$: recipeStatus = $recipes.get(recipeId); +let selectedModelId: string; +$: selectedModelId = recipe?.models?.[0] ?? ''; -let loading: boolean = false; -const onPullingRequest = async () => { - loading = true; - await studioClient.pullApplication(recipeId); +// Send recipe info to telemetry +let recipeTelemetry: string | undefined = undefined; +$: if (recipe && recipe.id !== recipeTelemetry) { + recipeTelemetry = recipe.id; + studioClient.telemetryLogUsage('recipe.open', { 'recipe.id': recipe.id, 'recipe.name': recipe.name }); } -const onClickRepository = () => { - if (recipe) { - studioClient.openURL(recipe.repository); - } +function setSelectedModel(modelId: string) { + selectedModelId = modelId; } - + - -
    -
    - -
    - -
    - -
    -
    Repository
    - -
    -
    - {#if recipeStatus !== undefined && recipeStatus.tasks.length > 0} - -
    -
    Repository
    - - {#if recipeStatus.state === 'error'} - - {/if} -
    -
    - {:else} - - {/if} -
    -
    -
    - - - + + + + + + + + + + + + +
    {#each recipe?.categories || [] as categoryId} + classes="bg-charcoal-800 p-1 text-xs w-fit" /> {/each}
    diff --git a/packages/frontend/src/pages/RecipeModels.svelte b/packages/frontend/src/pages/RecipeModels.svelte index 3d7078bb2..2367f4b5f 100644 --- a/packages/frontend/src/pages/RecipeModels.svelte +++ b/packages/frontend/src/pages/RecipeModels.svelte @@ -1,38 +1,56 @@ + {#if models} -
    -
    -
    +
    +
    +
    -
    + row="{row}" + headerBackground="bg-transparent" + on:update="{e => setModelToUse(e.detail)}"> +
    diff --git a/packages/frontend/src/pages/Recipes.svelte b/packages/frontend/src/pages/Recipes.svelte index bcbfd716c..b81eb0c93 100644 --- a/packages/frontend/src/pages/Recipes.svelte +++ b/packages/frontend/src/pages/Recipes.svelte @@ -10,23 +10,20 @@ $: categories = $catalog.categories;
    -
    - +
    + displayDescription="{false}" /> {#each categories as category} + secondaryBackground="bg-charcoal-700" + displayCategory="{false}" /> {/each}
    diff --git a/packages/frontend/src/pages/Registries.svelte b/packages/frontend/src/pages/Registries.svelte deleted file mode 100644 index 038b3ece8..000000000 --- a/packages/frontend/src/pages/Registries.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -
    Registries
    diff --git a/packages/frontend/src/pages/applications.ts b/packages/frontend/src/pages/applications.ts new file mode 100644 index 000000000..2fc23c4cb --- /dev/null +++ b/packages/frontend/src/pages/applications.ts @@ -0,0 +1,29 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ApplicationState } from '@shared/src/models/IApplicationState'; +import type { Task } from '@shared/src/models/ITask'; + +export interface ApplicationCell { + tasks?: Task[]; + appState: ApplicationState; + recipeId: string; + modelId: string; + appPorts: number[]; + modelPorts: number[]; +} diff --git a/packages/frontend/src/pages/dashboard.md b/packages/frontend/src/pages/dashboard.md new file mode 100644 index 000000000..5180fd9a4 --- /dev/null +++ b/packages/frontend/src/pages/dashboard.md @@ -0,0 +1,49 @@ +# AI studio + +## Installing a released version + +If you want to install a specific version of the extension, go to Podman Desktop UI > βš™ Settings > Extensions > Install a new extension from OCI Image. + +The name of the image to use is `ghcr.io/projectatomic/ai-studio:version_to_use`. + +You can get released tags for the image at https://github.com/projectatomic/studio-extension/pkgs/container/ai-studio. + +The released tags follow the major.minor.patch convention. + +If you want to install the last release version, use the latest tag. + +## Installing a development version + +You can install this extension from Podman Desktop UI > βš™ Settings > Extensions > Install a new extension from OCI Image. + +The name of the image to use is `ghcr.io/projectatomic/ai-studio:nightly`. + +You can get earlier tags for the image at https://github.com/projectatomic/studio-extension/pkgs/container/ai-studio. + +These images contain development versions of the extension. There is no stable release yet. + +## Running in development mode + +From the Podman Desktop sources folder: + +``` +$ yarn watch --extension-folder path-to-extension-sources-folder/packages/backend +``` + +## Providing a custom catalog + +The extension provides a default catalog, but you can build your own catalog by creating a file `$HOME/podman-desktop/ai-studio/catalog.json`. + +The catalog provides lists of categories, recipes, and models. + +Each recipe can belong to one or several categories. Each model can be used by one or several recipes. + +The format of the catalog is not stable nor versioned yet, you can see the current catalog's format [in the sources of the extension](https://github.com/projectatomic/studio-extension/blob/main/packages/backend/src/ai.json). + +## Packaging sample applications + +Sample applications may be added to the catalog. See [packaging guide](PACKAGING-GUIDE.md) for detailed information. + +## Feedback + +You can provide your feedback on the extension with [this form](https://forms.gle/tctQ4RtZSiMyQr3R8) or create [an issue on this repository](https://github.com/projectatomic/studio-extension/issues). diff --git a/packages/frontend/src/pages/dashboard.ts b/packages/frontend/src/pages/dashboard.ts new file mode 100644 index 000000000..d5685ff50 --- /dev/null +++ b/packages/frontend/src/pages/dashboard.ts @@ -0,0 +1,23 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import readme from './dashboard.md?raw'; + +export function getDashboardContent(): string { + return readme; +} diff --git a/packages/frontend/src/stores/application-states.ts b/packages/frontend/src/stores/application-states.ts new file mode 100644 index 000000000..f38aad523 --- /dev/null +++ b/packages/frontend/src/stores/application-states.ts @@ -0,0 +1,36 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Readable } from 'svelte/store'; +import { readable } from 'svelte/store'; +import { Messages } from '@shared/Messages'; +import { rpcBrowser, studioClient } from '/@/utils/client'; +import type { ApplicationState } from '@shared/src/models/IApplicationState'; + +export const applicationStates: Readable = readable([], set => { + const sub = rpcBrowser.subscribe(Messages.MSG_APPLICATIONS_STATE_UPDATE, msg => { + set(msg); + }); + // Initialize the store manually + studioClient.getApplicationsState().then(state => { + set(state); + }); + return () => { + sub.unsubscribe(); + }; +}); diff --git a/packages/frontend/src/stores/catalog.ts b/packages/frontend/src/stores/catalog.ts index b58c9d7bf..0a76acaec 100644 --- a/packages/frontend/src/stores/catalog.ts +++ b/packages/frontend/src/stores/catalog.ts @@ -1,8 +1,26 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import type { Readable } from 'svelte/store'; import { readable } from 'svelte/store'; -import { MSG_NEW_CATALOG_STATE } from '@shared/Messages'; +import { Messages } from '@shared/Messages'; import { rpcBrowser, studioClient } from '/@/utils/client'; -import type { Catalog } from '@shared/src/models/ICatalog'; +import type { ApplicationCatalog } from '@shared/src/models/IApplicationCatalog'; const emptyCatalog = { categories: [], @@ -10,8 +28,8 @@ const emptyCatalog = { recipes: [], }; -export const catalog: Readable = readable(emptyCatalog, set => { - const sub = rpcBrowser.subscribe(MSG_NEW_CATALOG_STATE, msg => { +export const catalog: Readable = readable(emptyCatalog, set => { + const sub = rpcBrowser.subscribe(Messages.MSG_NEW_CATALOG_STATE, msg => { set(msg); }); // Initialize the store manually diff --git a/packages/frontend/src/stores/conversations.ts b/packages/frontend/src/stores/conversations.ts new file mode 100644 index 000000000..ffb49214e --- /dev/null +++ b/packages/frontend/src/stores/conversations.ts @@ -0,0 +1,38 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Readable } from 'svelte/store'; +import { readable } from 'svelte/store'; +import { Messages } from '@shared/Messages'; +import { rpcBrowser, studioClient } from '/@/utils/client'; +import type { Conversation } from '@shared/src/models/IPlaygroundMessage'; + +// RPCReadable cannot be used here, as it is doing some debouncing, and we want +// to get the conversation as soon as the tokens arrive here, instead getting them by packets +export const conversations: Readable = readable([], set => { + const sub = rpcBrowser.subscribe(Messages.MSG_CONVERSATIONS_UPDATE, msg => { + set(msg); + }); + // Initialize the store manually + studioClient.getPlaygroundConversations().then(state => { + set(state); + }); + return () => { + sub.unsubscribe(); + }; +}); diff --git a/packages/frontend/src/stores/inferenceServers.ts b/packages/frontend/src/stores/inferenceServers.ts new file mode 100644 index 000000000..27f7d0799 --- /dev/null +++ b/packages/frontend/src/stores/inferenceServers.ts @@ -0,0 +1,27 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { RPCReadable } from '/@/stores/rpcReadable'; +import { Messages } from '@shared/Messages'; +import { studioClient } from '/@/utils/client'; +import type { InferenceServer } from '@shared/src/models/IInference'; + +export const inferenceServers = RPCReadable( + [], + [Messages.MSG_INFERENCE_SERVERS_UPDATE], + studioClient.getInferenceServers, +); diff --git a/packages/frontend/src/stores/local-models.ts b/packages/frontend/src/stores/local-models.ts deleted file mode 100644 index 311b308b8..000000000 --- a/packages/frontend/src/stores/local-models.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ModelInfo } from '@shared/src/models/IModelInfo'; -import type { Readable } from 'svelte/store'; -import { readable } from 'svelte/store'; -import { rpcBrowser, studioClient } from '/@/utils/client'; -import { MSG_NEW_LOCAL_MODELS_STATE } from '@shared/Messages'; - -export const localModels: Readable = readable([], set => { - const sub = rpcBrowser.subscribe(MSG_NEW_LOCAL_MODELS_STATE, msg => { - set(msg); - }); - // Initialize the store manually - studioClient.getLocalModels().then(v => { - set(v); - }); - return () => { - sub.unsubscribe(); - }; -}); diff --git a/packages/frontend/src/stores/localRepositories.ts b/packages/frontend/src/stores/localRepositories.ts new file mode 100644 index 000000000..af371b660 --- /dev/null +++ b/packages/frontend/src/stores/localRepositories.ts @@ -0,0 +1,11 @@ +import type { Readable } from 'svelte/store'; +import { Messages } from '@shared/Messages'; +import { studioClient } from '/@/utils/client'; +import type { LocalRepository } from '@shared/src/models/ILocalRepository'; +import { RPCReadable } from '/@/stores/rpcReadable'; + +export const localRepositories: Readable = RPCReadable( + [], + [Messages.MSG_LOCAL_REPOSITORY_UPDATE], + studioClient.getLocalRepositories, +); diff --git a/packages/frontend/src/stores/modelsInfo.spec.ts b/packages/frontend/src/stores/modelsInfo.spec.ts new file mode 100644 index 000000000..646a2f127 --- /dev/null +++ b/packages/frontend/src/stores/modelsInfo.spec.ts @@ -0,0 +1,77 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { Messages } from '@shared/Messages'; +import { rpcBrowser } from '../utils/client'; +import type { Unsubscriber } from 'svelte/store'; +import { modelsInfo } from './modelsInfo'; + +const mocks = vi.hoisted(() => { + return { + getModelsInfoMock: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('../utils/client', async () => { + const subscriber = new Map(); + const rpcBrowser = { + invoke: (msgId: string, _: unknown[]) => { + const f = subscriber.get(msgId); + f(); + }, + subscribe: (msgId: string, f: (msg: any) => void) => { + subscriber.set(msgId, f); + return { + unsubscribe: () => { + subscriber.clear(); + }, + }; + }, + }; + return { + rpcBrowser, + studioClient: { + getModelsInfo: mocks.getModelsInfoMock, + }, + }; +}); + +let unsubscriber: Unsubscriber | undefined; +beforeEach(() => { + vi.clearAllMocks(); + unsubscriber = modelsInfo.subscribe(_ => {}); +}); + +afterEach(() => { + if (unsubscriber) { + unsubscriber(); + unsubscriber = undefined; + } +}); + +test('check getLocalModels is called at subscription', async () => { + expect(mocks.getModelsInfoMock).toHaveBeenCalledOnce(); +}); + +test('check getLocalModels is called twice if event is fired (one at init, one for the event)', async () => { + rpcBrowser.invoke(Messages.MSG_NEW_MODELS_STATE); + // wait for the timeout in the debouncer + await new Promise(resolve => setTimeout(resolve, 600)); + expect(mocks.getModelsInfoMock).toHaveBeenCalledTimes(2); +}); diff --git a/packages/frontend/src/stores/modelsInfo.ts b/packages/frontend/src/stores/modelsInfo.ts new file mode 100644 index 000000000..5bf624a03 --- /dev/null +++ b/packages/frontend/src/stores/modelsInfo.ts @@ -0,0 +1,24 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import { studioClient } from '/@/utils/client'; +import { Messages } from '@shared/Messages'; +import { RPCReadable } from './rpcReadable'; + +export const modelsInfo = RPCReadable([], [Messages.MSG_NEW_MODELS_STATE], studioClient.getModelsInfo); diff --git a/packages/frontend/src/stores/playground-queries.ts b/packages/frontend/src/stores/playground-queries.ts deleted file mode 100644 index dece000d9..000000000 --- a/packages/frontend/src/stores/playground-queries.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Readable } from 'svelte/store'; -import { readable } from 'svelte/store'; -import type { QueryState } from '@shared/src/models/IPlaygroundQueryState'; -import { MSG_NEW_PLAYGROUND_QUERIES_STATE } from '@shared/Messages'; -import { rpcBrowser, studioClient } from '/@/utils/client'; - -export const playgroundQueries: Readable = readable([], set => { - const sub = rpcBrowser.subscribe(MSG_NEW_PLAYGROUND_QUERIES_STATE, msg => { - set(msg); - }); - // Initialize the store manually - studioClient.getPlaygroundQueriesState().then(state => { - set(state); - }); - return () => { - sub.unsubscribe(); - }; -}); diff --git a/packages/frontend/src/stores/playground-states.ts b/packages/frontend/src/stores/playground-states.ts deleted file mode 100644 index 934ef6262..000000000 --- a/packages/frontend/src/stores/playground-states.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Readable } from 'svelte/store'; -import { readable } from 'svelte/store'; -import { MSG_PLAYGROUNDS_STATE_UPDATE } from '@shared/Messages'; -import { rpcBrowser, studioClient } from '/@/utils/client'; -import type { PlaygroundState } from '@shared/src/models/IPlaygroundState'; - -export const playgroundStates: Readable = readable([], set => { - const sub = rpcBrowser.subscribe(MSG_PLAYGROUNDS_STATE_UPDATE, msg => { - set(msg); - }); - // Initialize the store manually - studioClient.getPlaygroundsState().then(state => { - set(state); - }); - return () => { - sub.unsubscribe(); - }; -}); diff --git a/packages/frontend/src/stores/playgrounds-v2.ts b/packages/frontend/src/stores/playgrounds-v2.ts new file mode 100644 index 000000000..e67f133d3 --- /dev/null +++ b/packages/frontend/src/stores/playgrounds-v2.ts @@ -0,0 +1,36 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Readable } from 'svelte/store'; +import { readable } from 'svelte/store'; +import { Messages } from '@shared/Messages'; +import { rpcBrowser, studioClient } from '/@/utils/client'; +import type { PlaygroundV2 } from '@shared/src/models/IPlaygroundV2'; + +export const playgrounds: Readable = readable([], set => { + const sub = rpcBrowser.subscribe(Messages.MSG_PLAYGROUNDS_V2_UPDATE, msg => { + set(msg); + }); + // Initialize the store manually + studioClient.getPlaygroundsV2().then(state => { + set(state); + }); + return () => { + sub.unsubscribe(); + }; +}); diff --git a/packages/frontend/src/stores/recipe.ts b/packages/frontend/src/stores/recipe.ts deleted file mode 100644 index b4d7d400c..000000000 --- a/packages/frontend/src/stores/recipe.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Readable } from 'svelte/store'; -import { derived, readable } from 'svelte/store'; -import { MSG_NEW_RECIPE_STATE } from '@shared/Messages'; -import { rpcBrowser, studioClient } from '/@/utils/client'; -import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; - -export const recipes: Readable> = readable>( - new Map(), - set => { - const sub = rpcBrowser.subscribe(MSG_NEW_RECIPE_STATE, msg => { - set(msg); - }); - // Initialize the store manually - studioClient.getPullingStatuses().then(state => { - set(state); - }); - return () => { - sub.unsubscribe(); - }; - }, -); - -export const modelsPulling = derived(recipes, $recipes => { - return Array.from($recipes.values()) - .flatMap(recipe => recipe.tasks) - .filter(task => 'model-pulling' in (task.labels || {})); -}); diff --git a/packages/frontend/src/stores/rpcReadable.spec.ts b/packages/frontend/src/stores/rpcReadable.spec.ts new file mode 100644 index 000000000..cc97105c2 --- /dev/null +++ b/packages/frontend/src/stores/rpcReadable.spec.ts @@ -0,0 +1,92 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, expect, test, vi } from 'vitest'; +import { RpcBrowser } from '@shared/src/messages/MessageProxy'; +import { RPCReadable } from './rpcReadable'; +import { studioClient, rpcBrowser } from '../utils/client'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; + +const mocks = vi.hoisted(() => { + return { + getModelsInfoMock: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('../utils/client', async () => { + const window = { + addEventListener: (_: string, _f: (message: unknown) => void) => {}, + } as unknown as Window; + + const api = { + postMessage: (message: unknown) => { + if (message && typeof message === 'object' && 'channel' in message) { + const f = rpcBrowser.subscribers.get(message.channel as string); + f?.(''); + } + }, + } as unknown as PodmanDesktopApi; + + const rpcBrowser = new RpcBrowser(window, api); + + return { + rpcBrowser: rpcBrowser, + studioClient: { + getModelsInfo: mocks.getModelsInfoMock, + }, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('check updater is called once at subscription', async () => { + const rpcWritable = RPCReadable([], [], () => { + studioClient.getModelsInfo(); + return Promise.resolve(['']); + }); + rpcWritable.subscribe(_ => {}); + expect(mocks.getModelsInfoMock).toHaveBeenCalledOnce(); +}); + +test('check updater is called twice if there is one event fired', async () => { + const rpcWritable = RPCReadable([], ['event'], () => { + studioClient.getModelsInfo(); + return Promise.resolve(['']); + }); + rpcWritable.subscribe(_ => {}); + rpcBrowser.invoke('event'); + // wait for the timeout in the debouncer + await new Promise(resolve => setTimeout(resolve, 600)); + expect(mocks.getModelsInfoMock).toHaveBeenCalledTimes(2); +}); + +test('check updater is called only twice because of the debouncer if there is more than one event in a row', async () => { + const rpcWritable = RPCReadable([], ['event2'], () => { + return studioClient.getModelsInfo(); + }); + rpcWritable.subscribe(_ => {}); + rpcBrowser.invoke('event2'); + rpcBrowser.invoke('event2'); + rpcBrowser.invoke('event2'); + rpcBrowser.invoke('event2'); + // wait for the timeout in the debouncer + await new Promise(resolve => setTimeout(resolve, 600)); + expect(mocks.getModelsInfoMock).toHaveBeenCalledTimes(2); +}); diff --git a/packages/frontend/src/stores/rpcReadable.ts b/packages/frontend/src/stores/rpcReadable.ts new file mode 100644 index 000000000..d30e0d0f1 --- /dev/null +++ b/packages/frontend/src/stores/rpcReadable.ts @@ -0,0 +1,93 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { writable, type Invalidator, type Subscriber, type Unsubscriber, type Readable } from 'svelte/store'; +import { rpcBrowser } from '../utils/client'; +import type { Subscriber as SharedSubscriber } from '@shared/src/messages/MessageProxy'; + +export function RPCReadable( + value: T, + // The event used to subscribe to a webview postMessage event + subscriptionEvents: string[], + // The initialization function that will be called to update the store at creation. + // For example, you can pass in a custom function such as "getPullingStatuses". + updater: () => Promise, +): Readable { + let timeoutId: NodeJS.Timeout | undefined; + let timeoutThrottle: NodeJS.Timeout | undefined; + + const debouncedUpdater = debounce(updater); + const origWritable = writable(value); + + function subscribe(this: void, run: Subscriber, invalidate?: Invalidator): Unsubscriber { + const rcpSubscribes: SharedSubscriber[] = []; + + for (const subscriptionEvent of subscriptionEvents) { + const rcpSubscribe = rpcBrowser.subscribe(subscriptionEvent, (_: unknown) => { + debouncedUpdater() + .then(v => origWritable.set(v)) + .catch((e: unknown) => console.error('failed at updating store', String(e))); + }); + rcpSubscribes.push(rcpSubscribe); + } + + updater() + .then(v => origWritable.set(v)) + .catch((e: unknown) => console.error('failed at init store', String(e))); + + const unsubscribe = origWritable.subscribe(run, invalidate); + return () => { + rcpSubscribes.forEach(r => r.unsubscribe()); + unsubscribe(); + }; + } + + function debounce(func: () => Promise): () => Promise { + return () => + new Promise(resolve => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + + // throttle timeout, ask after 5s to update anyway to have at least UI being refreshed every 5s if there is a lot of events + // because debounce will defer all the events until the end so it's not so nice from UI side. + if (!timeoutThrottle) { + timeoutThrottle = setTimeout(() => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + resolve(func()); + }, 5000); + } + + timeoutId = setTimeout(() => { + if (timeoutThrottle) { + clearTimeout(timeoutThrottle); + timeoutThrottle = undefined; + } + resolve(func()); + }, 500); + }); + } + + return { + subscribe, + }; +} diff --git a/packages/frontend/src/stores/snippetLanguages.ts b/packages/frontend/src/stores/snippetLanguages.ts new file mode 100644 index 000000000..b7d434c5a --- /dev/null +++ b/packages/frontend/src/stores/snippetLanguages.ts @@ -0,0 +1,28 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { Readable } from 'svelte/store'; +import { Messages } from '@shared/Messages'; +import { studioClient } from '/@/utils/client'; +import { RPCReadable } from '/@/stores/rpcReadable'; +import type { Language } from 'postman-code-generators'; + +export const snippetLanguages: Readable = RPCReadable( + [], + [Messages.MSG_SUPPORTED_LANGUAGES_UPDATE], + studioClient.getSnippetLanguages, +); diff --git a/packages/frontend/src/stores/tasks.ts b/packages/frontend/src/stores/tasks.ts new file mode 100644 index 000000000..cb4ae0e1f --- /dev/null +++ b/packages/frontend/src/stores/tasks.ts @@ -0,0 +1,24 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { studioClient } from '/@/utils/client'; +import { Messages } from '@shared/Messages'; +import { RPCReadable } from './rpcReadable'; +import type { Task } from '@shared/src/models/ITask'; + +export const tasks = RPCReadable([], [Messages.MSG_TASKS_UPDATE], studioClient.getTasks); diff --git a/packages/frontend/src/utils/categoriesUtils.ts b/packages/frontend/src/utils/categoriesUtils.ts index a705d0ea6..1bbd34309 100644 --- a/packages/frontend/src/utils/categoriesUtils.ts +++ b/packages/frontend/src/utils/categoriesUtils.ts @@ -1,10 +1,30 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import type { IconDefinition } from '@fortawesome/free-regular-svg-icons'; -import { faAlignLeft, faImages, faQuestion } from '@fortawesome/free-solid-svg-icons'; -export const getIcon = (category: string | undefined): IconDefinition | undefined => { - switch (category) { +import { faAlignLeft, faEdit, faImages, faQuestion } from '@fortawesome/free-solid-svg-icons'; +export const getIcon = (scope: string | undefined): IconDefinition | undefined => { + switch (scope) { case 'natural-language-processing': return faAlignLeft; + case 'generator': + return faEdit; case 'computer-vision': return faImages; default: diff --git a/packages/frontend/src/utils/client.ts b/packages/frontend/src/utils/client.ts index ace1b10b2..837c4cf26 100644 --- a/packages/frontend/src/utils/client.ts +++ b/packages/frontend/src/utils/client.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import type { StudioAPI } from '@shared/src/StudioAPI'; import { RpcBrowser } from '@shared/src/messages/MessageProxy'; import type { RouterState } from '/@/models/IRouterState'; diff --git a/packages/frontend/src/utils/localRepositoriesUtils.ts b/packages/frontend/src/utils/localRepositoriesUtils.ts new file mode 100644 index 000000000..b87a9c545 --- /dev/null +++ b/packages/frontend/src/utils/localRepositoriesUtils.ts @@ -0,0 +1,8 @@ +import type { LocalRepository } from '@shared/src/models/ILocalRepository'; + +export const findLocalRepositoryByRecipeId = ( + store: LocalRepository[], + recipeId: string, +): LocalRepository | undefined => { + return store.find(local => !!local.labels && 'recipe-id' in local.labels && local.labels['recipe-id'] === recipeId); +}; diff --git a/packages/frontend/src/utils/printers.ts b/packages/frontend/src/utils/printers.ts new file mode 100644 index 000000000..e2d3e5553 --- /dev/null +++ b/packages/frontend/src/utils/printers.ts @@ -0,0 +1,28 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export function displayPorts(ports: number[]): string { + if (!ports || ports.length === 0) { + return ''; + } + if (ports.length === 1) { + return `PORT ${ports[0]}`; + } else { + return `PORTS ${ports.join(', ')}`; + } +} diff --git a/packages/frontend/src/utils/taskUtils.ts b/packages/frontend/src/utils/taskUtils.ts new file mode 100644 index 000000000..d97673b17 --- /dev/null +++ b/packages/frontend/src/utils/taskUtils.ts @@ -0,0 +1,32 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Task } from '@shared/src/models/ITask'; + +export const filterByLabel = (tasks: Task[], requestedLabels: { [key: string]: string }): Task[] => { + return tasks.filter(task => { + const labels = task.labels; + if (labels === undefined) return false; + + for (const [key, value] of Object.entries(requestedLabels)) { + if (!(key in labels) || labels[key] !== value) return false; + } + + return true; + }); +}; diff --git a/packages/frontend/src/utils/versionControlUtils.ts b/packages/frontend/src/utils/versionControlUtils.ts index 59ca9e1df..bd9ef05bb 100644 --- a/packages/frontend/src/utils/versionControlUtils.ts +++ b/packages/frontend/src/utils/versionControlUtils.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + const GITHUB_PREFIX = 'https://github.com/'; export const getDisplayName = (link: string | undefined): string => { diff --git a/packages/shared/Messages.ts b/packages/shared/Messages.ts index 637b7ef76..90ca0497a 100644 --- a/packages/shared/Messages.ts +++ b/packages/shared/Messages.ts @@ -1,5 +1,30 @@ -export const MSG_PLAYGROUNDS_STATE_UPDATE = 'playgrounds-state-update'; -export const MSG_NEW_PLAYGROUND_QUERIES_STATE = 'new-playground-queries-state'; -export const MSG_NEW_CATALOG_STATE = 'new-catalog-state'; -export const MSG_NEW_RECIPE_STATE = 'new-recipe-state'; -export const MSG_NEW_LOCAL_MODELS_STATE = 'new-local-models-state'; +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export enum Messages { + MSG_NEW_CATALOG_STATE = 'new-catalog-state', + MSG_TASKS_UPDATE = 'tasks-update', + MSG_NEW_MODELS_STATE = 'new-models-state', + MSG_APPLICATIONS_STATE_UPDATE = 'applications-state-update', + MSG_LOCAL_REPOSITORY_UPDATE = 'local-repository-update', + MSG_INFERENCE_SERVERS_UPDATE = 'inference-servers-update', + MSG_MONITORING_UPDATE = 'monitoring-update', + MSG_SUPPORTED_LANGUAGES_UPDATE = 'supported-languages-supported', + MSG_CONVERSATIONS_UPDATE = 'conversations-update', + MSG_PLAYGROUNDS_V2_UPDATE = 'playgrounds-v2-update', +} diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index 5ea1990eb..6f75c9314 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -1,24 +1,155 @@ -import type { RecipeStatus } from './models/IRecipeStatus'; +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import type { ModelInfo } from './models/IModelInfo'; -import type { QueryState } from './models/IPlaygroundQueryState'; -import type { Catalog } from './models/ICatalog'; -import type { PlaygroundState } from './models/IPlaygroundState'; +import type { ApplicationCatalog } from './models/IApplicationCatalog'; +import type { TelemetryTrustedValue } from '@podman-desktop/api'; +import type { ApplicationState } from './models/IApplicationState'; +import type { Task } from './models/ITask'; +import type { LocalRepository } from './models/ILocalRepository'; +import type { InferenceServer } from './models/IInference'; +import type { RequestOptions } from './models/RequestOptions'; +import type { Language } from 'postman-code-generators'; +import type { CreationInferenceServerOptions } from './models/InferenceServerConfig'; +import type { ModelOptions } from './models/IModelOptions'; +import type { Conversation } from './models/IPlaygroundMessage'; +import type { PlaygroundV2 } from './models/IPlaygroundV2'; export abstract class StudioAPI { abstract ping(): Promise; - abstract getCatalog(): Promise; - abstract getPullingStatus(recipeId: string): Promise; - abstract getPullingStatuses(): Promise>; - abstract pullApplication(recipeId: string): Promise; + abstract getCatalog(): Promise; + abstract pullApplication(recipeId: string, modelId: string): Promise; abstract openURL(url: string): Promise; + abstract openFile(file: string): Promise; + /** + * Get the information of models saved locally into the user's directory + */ + abstract getModelsInfo(): Promise; /** - * Get the information of models saved locally into the extension's storage directory + * Delete the folder containing the model from local storage */ - abstract getLocalModels(): Promise; + abstract requestRemoveLocalModel(modelId: string): Promise; + + abstract getModelsDirectory(): Promise; + + abstract navigateToContainer(containerId: string): Promise; + abstract navigateToPod(podId: string): Promise; + + abstract getApplicationsState(): Promise; + abstract requestRemoveApplication(recipeId: string, modelId: string): Promise; + abstract requestRestartApplication(recipeId: string, modelId: string): Promise; + abstract requestOpenApplication(recipeId: string, modelId: string): Promise; + + abstract telemetryLogUsage(eventName: string, data?: Record): Promise; + abstract telemetryLogError(eventName: string, data?: Record): Promise; - abstract startPlayground(modelId: string): Promise; - abstract stopPlayground(modelId: string): Promise; - abstract askPlayground(modelId: string, prompt: string): Promise; - abstract getPlaygroundQueriesState(): Promise; - abstract getPlaygroundsState(): Promise; + abstract getLocalRepositories(): Promise; + + abstract getTasks(): Promise; + + /** + * Open the VSCode editor + * @param directory the directory to open the editor from + */ + abstract openVSCode(directory: string): Promise; + + /** + * Download a model from the catalog + * @param modelId the id of the model we want to download + */ + abstract downloadModel(modelId: string): Promise; + + /** + * Get inference servers + */ + abstract getInferenceServers(): Promise; + + /** + * Request to start an inference server + * @param options The options to use + * + * @return a tracking identifier to follow progress + */ + abstract requestCreateInferenceServer(options: CreationInferenceServerOptions): Promise; + + /** + * Start an inference server + * @param containerId the container id of the inference server + */ + abstract startInferenceServer(containerId: string): Promise; + + /** + * Stop an inference server + * @param containerId the container id of the inference server + */ + abstract stopInferenceServer(containerId: string): Promise; + + /** + * Delete an inference server container + * @param containerId the container id of the inference server + */ + abstract requestDeleteInferenceServer(containerId: string): Promise; + + /** + * Return a free random port on the host machine + */ + abstract getHostFreePort(): Promise; + + /** + * Submit a user input to the Playground linked to a conversation, model, and inference server + * @param containerId the container id of the inference server we want to use + * @param modelId the model to use + * @param conversationId the conversation to input the message in + * @param userInput the user input, e.g. 'What is the capital of France ?' + * @param options the options for the model, e.g. temperature + */ + abstract submitPlaygroundMessage( + containerId: string, + userInput: string, + systemPrompt: string, + options?: ModelOptions, + ): Promise; + + /** + * Return the conversations + */ + abstract getPlaygroundConversations(): Promise; + + /** + * Return the list of supported languages to generate code from. + */ + abstract getSnippetLanguages(): Promise; + + /** + * return a code snippet as a string matching the arguments and options provided + * @param options the options for the request + * @param language the language to use + * @param variant the variant of the language + */ + abstract createSnippet(options: RequestOptions, language: string, variant: string): Promise; + + abstract requestCreatePlayground(name: string, model: ModelInfo): Promise; + + abstract getPlaygroundsV2(): Promise; + + /** + * Delete a conversation + * @param conversationId the conversation identifier that will be deleted + */ + abstract requestDeleteConversation(conversationId: string): Promise; } diff --git a/packages/shared/src/messages/MessageProxy.spec.ts b/packages/shared/src/messages/MessageProxy.spec.ts index 656b9b9e6..249b43695 100644 --- a/packages/shared/src/messages/MessageProxy.spec.ts +++ b/packages/shared/src/messages/MessageProxy.spec.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import { test, expect, beforeAll } from 'vitest'; import { RpcBrowser, RpcExtension } from './MessageProxy'; import type { Webview } from '@podman-desktop/api'; @@ -81,3 +99,14 @@ test('Test register instance with async', async () => { const proxy = rpcBrowser.getProxy(); expect(await proxy.ping()).toBe('pong'); }); + +test('Test raising exception', async () => { + const rpcExtension = new RpcExtension(webview); + const rpcBrowser = new RpcBrowser(window, api); + + rpcExtension.register('raiseError', () => { + throw new Error('big error'); + }); + + await expect(rpcBrowser.invoke('raiseError')).rejects.toThrow('big error'); +}); diff --git a/packages/shared/src/messages/MessageProxy.ts b/packages/shared/src/messages/MessageProxy.ts index c844df415..fbb7d2b70 100644 --- a/packages/shared/src/messages/MessageProxy.ts +++ b/packages/shared/src/messages/MessageProxy.ts @@ -78,12 +78,23 @@ export class RpcExtension { body: result, status: 'success', } as IMessageResponse); - } catch (e) { + } catch (err: unknown) { + let errorMessage: string; + // Depending on the object throw we try to extract the error message + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'string') { + errorMessage = err; + } else { + errorMessage = String(err); + } + await this.webview.postMessage({ id: message.id, channel: message.channel, body: undefined, - error: `Something went wrong on channel ${message.channel}: ${String(e)}`, + status: 'error', + error: errorMessage, } as IMessageResponse); } }); @@ -174,6 +185,10 @@ export class RpcBrowser { // Generate a unique id for the request const requestId = this.getUniqueId(); + const promise = new Promise((resolve, reject) => { + this.promises.set(requestId, { resolve, reject }); + }); + // Post the message this.api.postMessage({ id: requestId, @@ -190,9 +205,7 @@ export class RpcBrowser { }, 5000); // Create a Promise - return new Promise((resolve, reject) => { - this.promises.set(requestId, { resolve, reject }); - }); + return promise; } // TODO(feloy) need to subscribe several times? diff --git a/packages/shared/src/models/IApplicationCatalog.ts b/packages/shared/src/models/IApplicationCatalog.ts new file mode 100644 index 000000000..672da5005 --- /dev/null +++ b/packages/shared/src/models/IApplicationCatalog.ts @@ -0,0 +1,27 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Category } from './ICategory'; +import type { ModelInfo } from './IModelInfo'; +import type { Recipe } from './IRecipe'; + +export interface ApplicationCatalog { + recipes: Recipe[]; + models: ModelInfo[]; + categories: Category[]; +} diff --git a/packages/shared/src/models/IApplicationState.ts b/packages/shared/src/models/IApplicationState.ts new file mode 100644 index 000000000..b4503eb94 --- /dev/null +++ b/packages/shared/src/models/IApplicationState.ts @@ -0,0 +1,27 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { PodInfo } from '@podman-desktop/api'; + +export interface ApplicationState { + recipeId: string; + modelId: string; + pod: PodInfo; + appPorts: number[]; + modelPorts: number[]; +} diff --git a/packages/shared/src/models/ICatalog.ts b/packages/shared/src/models/ICatalog.ts deleted file mode 100644 index 5d3e64343..000000000 --- a/packages/shared/src/models/ICatalog.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Category } from './ICategory'; -import type { ModelInfo } from './IModelInfo'; -import type { Recipe } from './IRecipe'; - -export interface Catalog { - recipes: Recipe[]; - models: ModelInfo[]; - categories: Category[]; -} diff --git a/packages/shared/src/models/ICategory.ts b/packages/shared/src/models/ICategory.ts index 7980277dd..cbe87d47f 100644 --- a/packages/shared/src/models/ICategory.ts +++ b/packages/shared/src/models/ICategory.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + export interface Category { id: string; name: string; diff --git a/packages/shared/src/models/IInference.ts b/packages/shared/src/models/IInference.ts new file mode 100644 index 000000000..6f32f0aa7 --- /dev/null +++ b/packages/shared/src/models/IInference.ts @@ -0,0 +1,56 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { ModelInfo } from './IModelInfo'; + +export interface InferenceServer { + /** + * Supported models + */ + models: ModelInfo[]; + /** + * Container info + */ + container: { + engineId: string; + containerId: string; + }; + connection: { + port: number; + }; + /** + * Inference server status + */ + status: 'stopped' | 'running'; + /** + * Health check + */ + health?: { + Status: string; + FailingStreak: number; + Log: Array<{ + Start: string; + End: string; + ExitCode: number; + Output: string; + }>; + }; + /** + * Exit code + */ + exit?: number; +} diff --git a/packages/shared/src/models/ILocalModelInfo.ts b/packages/shared/src/models/ILocalModelInfo.ts index ecdeb88b0..270deb604 100644 --- a/packages/shared/src/models/ILocalModelInfo.ts +++ b/packages/shared/src/models/ILocalModelInfo.ts @@ -1,7 +1,24 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + export interface LocalModelInfo { - id: string; file: string; path: string; - size: number; - creation: Date; + size?: number; + creation?: Date; } diff --git a/packages/shared/src/models/ILocalRepository.ts b/packages/shared/src/models/ILocalRepository.ts new file mode 100644 index 000000000..7ee48617f --- /dev/null +++ b/packages/shared/src/models/ILocalRepository.ts @@ -0,0 +1,4 @@ +export interface LocalRepository { + path: string; + labels: { [id: string]: string }; +} diff --git a/packages/shared/src/models/IModelInfo.ts b/packages/shared/src/models/IModelInfo.ts index 422078fce..8c3800931 100644 --- a/packages/shared/src/models/IModelInfo.ts +++ b/packages/shared/src/models/IModelInfo.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + import type { LocalModelInfo } from './ILocalModelInfo'; export interface ModelInfo { @@ -6,8 +24,8 @@ export interface ModelInfo { description: string; hw: string; registry: string; - popularity: number; license: string; url: string; file?: LocalModelInfo; + state?: 'deleting'; } diff --git a/packages/shared/src/models/IModelOptions.ts b/packages/shared/src/models/IModelOptions.ts new file mode 100644 index 000000000..a8e9319e9 --- /dev/null +++ b/packages/shared/src/models/IModelOptions.ts @@ -0,0 +1,5 @@ +export interface ModelOptions { + temperature?: number; + max_tokens?: number; + top_p?: number; +} diff --git a/packages/shared/src/models/IModelResponse.ts b/packages/shared/src/models/IModelResponse.ts index 820c43d9c..f2394d0b3 100644 --- a/packages/shared/src/models/IModelResponse.ts +++ b/packages/shared/src/models/IModelResponse.ts @@ -1,10 +1,28 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + export interface ModelResponse { created: number; object: string; id: string; model: string; choices: ModelResponseChoice[]; - usage: ModelResponseUsage; + usage?: ModelResponseUsage; } export interface ModelResponseChoice { diff --git a/packages/shared/src/models/IPlaygroundMessage.ts b/packages/shared/src/models/IPlaygroundMessage.ts new file mode 100644 index 000000000..8d7229688 --- /dev/null +++ b/packages/shared/src/models/IPlaygroundMessage.ts @@ -0,0 +1,62 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ModelOptions } from './IModelOptions'; + +export interface ChatMessage { + id: string; + role: 'system' | 'user' | 'assistant'; + content?: string; + timestamp: number; +} + +export interface AssistantChat extends ChatMessage { + role: 'assistant'; + completed?: number; +} + +export interface PendingChat extends AssistantChat { + completed: undefined; + choices: Choice[]; +} + +export interface UserChat extends ChatMessage { + role: 'user'; + options?: ModelOptions; +} + +export interface Conversation { + id: string; + messages: ChatMessage[]; +} + +export interface Choice { + content: string; +} + +export function isAssistantChat(msg: ChatMessage): msg is AssistantChat { + return msg.role === 'assistant'; +} + +export function isUserChat(msg: ChatMessage): msg is UserChat { + return msg.role === 'user'; +} + +export function isPendingChat(msg: ChatMessage): msg is PendingChat { + return isAssistantChat(msg) && !msg.completed; +} diff --git a/packages/shared/src/models/IPlaygroundQueryState.ts b/packages/shared/src/models/IPlaygroundQueryState.ts deleted file mode 100644 index 9f8ae9349..000000000 --- a/packages/shared/src/models/IPlaygroundQueryState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ModelResponse } from './IModelResponse'; - -export interface QueryState { - id: number; - modelId: string; - prompt: string; - response?: ModelResponse; -} diff --git a/packages/shared/src/models/IPlaygroundState.ts b/packages/shared/src/models/IPlaygroundState.ts deleted file mode 100644 index 9ff588228..000000000 --- a/packages/shared/src/models/IPlaygroundState.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type PlaygroundStatus = 'none' | 'stopped' | 'running' | 'starting' | 'stopping' | 'error'; - -export interface PlaygroundState { - container?: { - containerId: string; - port: number; - engineId: string; - }; - modelId: string; - status: PlaygroundStatus; -} diff --git a/packages/shared/src/models/IPlaygroundV2.ts b/packages/shared/src/models/IPlaygroundV2.ts new file mode 100644 index 000000000..8daf6ac35 --- /dev/null +++ b/packages/shared/src/models/IPlaygroundV2.ts @@ -0,0 +1,5 @@ +export interface PlaygroundV2 { + id: string; + name: string; + modelId: string; +} diff --git a/packages/shared/src/models/IRecipe.ts b/packages/shared/src/models/IRecipe.ts index 7f60b8e75..6bad3848a 100644 --- a/packages/shared/src/models/IRecipe.ts +++ b/packages/shared/src/models/IRecipe.ts @@ -1,3 +1,21 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + export interface Recipe { id?: string; name: string; @@ -5,6 +23,7 @@ export interface Recipe { description: string; icon?: string; repository: string; + ref?: string; readme: string; config?: string; models?: string[]; diff --git a/packages/shared/src/models/IRecipeModelIndex.ts b/packages/shared/src/models/IRecipeModelIndex.ts new file mode 100644 index 000000000..f2643b290 --- /dev/null +++ b/packages/shared/src/models/IRecipeModelIndex.ts @@ -0,0 +1,22 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface RecipeModelIndex { + recipeId: string; + modelId: string; +} diff --git a/packages/shared/src/models/IRecipeStatus.ts b/packages/shared/src/models/IRecipeStatus.ts deleted file mode 100644 index dad911de1..000000000 --- a/packages/shared/src/models/IRecipeStatus.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Task } from './ITask'; - -export type RecipeStatusState = 'none' | 'loading' | 'pulled' | 'running' | 'error'; - -export interface RecipeStatus { - recipeId: string; - tasks: Task[]; - state: RecipeStatusState; -} diff --git a/packages/shared/src/models/ITask.ts b/packages/shared/src/models/ITask.ts index 689db0d45..20fc0a9a3 100644 --- a/packages/shared/src/models/ITask.ts +++ b/packages/shared/src/models/ITask.ts @@ -1,9 +1,28 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + export type TaskState = 'loading' | 'error' | 'success'; export interface Task { - id: string; + readonly id: string; + error?: string; + name: string; state: TaskState; progress?: number; - name: string; labels?: { [id: string]: string }; } diff --git a/packages/shared/src/models/InferenceServerConfig.ts b/packages/shared/src/models/InferenceServerConfig.ts new file mode 100644 index 000000000..48df109fd --- /dev/null +++ b/packages/shared/src/models/InferenceServerConfig.ts @@ -0,0 +1,44 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import type { ModelInfo } from './IModelInfo'; + +export type CreationInferenceServerOptions = Partial & { modelsInfo: ModelInfo[] }; + +export interface InferenceServerConfig { + /** + * Port to expose + */ + port: number; + /** + * The identifier of the container provider to use + */ + providerId?: string; + /** + * Image to use + */ + image: string; + /** + * Labels to use for the container + */ + labels: { [id: string]: string }; + + /** + * Model info for the models + */ + modelsInfo: ModelInfo[]; +} diff --git a/packages/shared/src/models/RequestOptions.ts b/packages/shared/src/models/RequestOptions.ts new file mode 100644 index 000000000..03d5d7959 --- /dev/null +++ b/packages/shared/src/models/RequestOptions.ts @@ -0,0 +1,13 @@ +export interface RequestOptions { + url: string; + method?: string; + header?: { + key?: string; + value?: string; + system?: boolean; + }[]; + body?: { + mode: 'raw'; + raw?: string; + }; +} diff --git a/types/markdown.d.ts b/types/markdown.d.ts new file mode 100644 index 000000000..e38169cc3 --- /dev/null +++ b/types/markdown.d.ts @@ -0,0 +1,22 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +declare module '*.md?raw' { + const contents: string; + export = contents; +} diff --git a/types/mustache.d.ts b/types/mustache.d.ts new file mode 100644 index 000000000..dac3aa899 --- /dev/null +++ b/types/mustache.d.ts @@ -0,0 +1,22 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +declare module '*.mustache?raw' { + const contents: string; + export = contents; +} diff --git a/types/postman-code-generators.d.ts b/types/postman-code-generators.d.ts new file mode 100644 index 000000000..776b99bb9 --- /dev/null +++ b/types/postman-code-generators.d.ts @@ -0,0 +1,45 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +declare module 'postman-code-generators' { + import type { Request } from 'postman-collection'; + + export function getLanguageList(): Language[]; + + export interface Language { + key: string; + label: string; + syntax_mode: string; + variants: LanguageVariant[], + } + + export interface LanguageVariant { + key: string; + } + + export function getOptions(language: string, variant: string, callback: (error: unknown, options: Option[]) => void): void; + + export interface Option { + name: string; + id: string; + type: string; + default: string | number | boolean; + description: string; + } + + export function convert(language: string, variant: string, request: Request, options: Record, callback: (error: unknown, snippet: string | undefined) => void): void; +} diff --git a/yarn.lock b/yarn.lock index 8103f8693..a1553be50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,15 +25,6 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@asamuzakjp/dom-selector@^2.0.1": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz#160f601d9a465bbdf641410afdc527f37325506e" - integrity sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ== - dependencies: - bidi-js "^1.0.3" - css-tree "^2.3.1" - is-potential-custom-element-name "^1.0.1" - "@babel/code-frame@^7.10.4": version "7.23.5" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz" @@ -61,29 +52,22 @@ chalk "^2.4.2" js-tokens "^4.0.0" -"@babel/parser@^7.23.3": - version "7.23.6" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz" - integrity sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ== +"@babel/parser@^7.23.6": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.9.tgz#7b903b6149b0f8fa7ad564af646c4c38a77fc44b" + integrity sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA== -"@babel/runtime@^7.12.5", "@babel/runtime@^7.21.0": - version "7.23.6" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz" - integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ== - dependencies: - regenerator-runtime "^0.14.0" - -"@babel/runtime@^7.9.2": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.21.0", "@babel/runtime@^7.9.2": version "7.23.8" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw== dependencies: regenerator-runtime "^0.14.0" -"@babel/types@^7.23.3": - version "7.23.6" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz" - integrity sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg== +"@babel/types@^7.23.6": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.9.tgz#1dd7b59a9a2b5c87f8b41e52770b5ecbf492e002" + integrity sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q== dependencies: "@babel/helper-string-parser" "^7.23.4" "@babel/helper-validator-identifier" "^7.22.20" @@ -94,120 +78,120 @@ resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@esbuild/aix-ppc64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.10.tgz#fb3922a0183d27446de00cf60d4f7baaadf98d84" - integrity sha512-Q+mk96KJ+FZ30h9fsJl+67IjNJm3x2eX+GBWGmocAKgzp27cowCOOqSdscX80s0SpdFXZnIv/+1xD1EctFx96Q== - -"@esbuild/android-arm64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.10.tgz#ef31015416dd79398082409b77aaaa2ade4d531a" - integrity sha512-1X4CClKhDgC3by7k8aOWZeBXQX8dHT5QAMCAQDArCLaYfkppoARvh0fit3X2Qs+MXDngKcHv6XXyQCpY0hkK1Q== - -"@esbuild/android-arm@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.10.tgz#1c23c7e75473aae9fb323be5d9db225142f47f52" - integrity sha512-7W0bK7qfkw1fc2viBfrtAEkDKHatYfHzr/jKAHNr9BvkYDXPcC6bodtm8AyLJNNuqClLNaeTLuwURt4PRT9d7w== - -"@esbuild/android-x64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.10.tgz#df6a4e6d6eb8da5595cfce16d4e3f6bc24464707" - integrity sha512-O/nO/g+/7NlitUxETkUv/IvADKuZXyH4BHf/g/7laqKC4i/7whLpB0gvpPc2zpF0q9Q6FXS3TS75QHac9MvVWw== - -"@esbuild/darwin-arm64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.10.tgz#8462a55db07c1b2fad61c8244ce04469ef1043be" - integrity sha512-YSRRs2zOpwypck+6GL3wGXx2gNP7DXzetmo5pHXLrY/VIMsS59yKfjPizQ4lLt5vEI80M41gjm2BxrGZ5U+VMA== - -"@esbuild/darwin-x64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.10.tgz#d1de20bfd41bb75b955ba86a6b1004539e8218c1" - integrity sha512-alfGtT+IEICKtNE54hbvPg13xGBe4GkVxyGWtzr+yHO7HIiRJppPDhOKq3zstTcVf8msXb/t4eavW3jCDpMSmA== - -"@esbuild/freebsd-arm64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.10.tgz#16904879e34c53a2e039d1284695d2db3e664d57" - integrity sha512-dMtk1wc7FSH8CCkE854GyGuNKCewlh+7heYP/sclpOG6Cectzk14qdUIY5CrKDbkA/OczXq9WesqnPl09mj5dg== - -"@esbuild/freebsd-x64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.10.tgz#8ad9e5ca9786ca3f1ef1411bfd10b08dcd9d4cef" - integrity sha512-G5UPPspryHu1T3uX8WiOEUa6q6OlQh6gNl4CO4Iw5PS+Kg5bVggVFehzXBJY6X6RSOMS8iXDv2330VzaObm4Ag== - -"@esbuild/linux-arm64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.10.tgz#d82cf2c590faece82d28bbf1cfbe36f22ae25bd2" - integrity sha512-QxaouHWZ+2KWEj7cGJmvTIHVALfhpGxo3WLmlYfJ+dA5fJB6lDEIg+oe/0//FuyVHuS3l79/wyBxbHr0NgtxJQ== - -"@esbuild/linux-arm@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.10.tgz#477b8e7c7bcd34369717b04dd9ee6972c84f4029" - integrity sha512-j6gUW5aAaPgD416Hk9FHxn27On28H4eVI9rJ4az7oCGTFW48+LcgNDBN+9f8rKZz7EEowo889CPKyeaD0iw9Kg== - -"@esbuild/linux-ia32@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.10.tgz#d55ff822cf5b0252a57112f86857ff23be6cab0e" - integrity sha512-4ub1YwXxYjj9h1UIZs2hYbnTZBtenPw5NfXCRgEkGb0b6OJ2gpkMvDqRDYIDRjRdWSe/TBiZltm3Y3Q8SN1xNg== - -"@esbuild/linux-loong64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.10.tgz#a9ad057d7e48d6c9f62ff50f6f208e331c4543c7" - integrity sha512-lo3I9k+mbEKoxtoIbM0yC/MZ1i2wM0cIeOejlVdZ3D86LAcFXFRdeuZmh91QJvUTW51bOK5W2BznGNIl4+mDaA== - -"@esbuild/linux-mips64el@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.10.tgz#b011a96924773d60ebab396fbd7a08de66668179" - integrity sha512-J4gH3zhHNbdZN0Bcr1QUGVNkHTdpijgx5VMxeetSk6ntdt+vR1DqGmHxQYHRmNb77tP6GVvD+K0NyO4xjd7y4A== - -"@esbuild/linux-ppc64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.10.tgz#5d8b59929c029811e473f2544790ea11d588d4dd" - integrity sha512-tgT/7u+QhV6ge8wFMzaklOY7KqiyitgT1AUHMApau32ZlvTB/+efeCtMk4eXS+uEymYK249JsoiklZN64xt6oQ== - -"@esbuild/linux-riscv64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.10.tgz#292b06978375b271bd8bc0a554e0822957508d22" - integrity sha512-0f/spw0PfBMZBNqtKe5FLzBDGo0SKZKvMl5PHYQr3+eiSscfJ96XEknCe+JoOayybWUFQbcJTrk946i3j9uYZA== - -"@esbuild/linux-s390x@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.10.tgz#d30af63530f8d4fa96930374c9dd0d62bf59e069" - integrity sha512-pZFe0OeskMHzHa9U38g+z8Yx5FNCLFtUnJtQMpwhS+r4S566aK2ci3t4NCP4tjt6d5j5uo4h7tExZMjeKoehAA== - -"@esbuild/linux-x64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.10.tgz#898c72eeb74d9f2fb43acf316125b475548b75ce" - integrity sha512-SpYNEqg/6pZYoc+1zLCjVOYvxfZVZj6w0KROZ3Fje/QrM3nfvT2llI+wmKSrWuX6wmZeTapbarvuNNK/qepSgA== - -"@esbuild/netbsd-x64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.10.tgz#fd473a5ae261b43eab6dad4dbd5a3155906e6c91" - integrity sha512-ACbZ0vXy9zksNArWlk2c38NdKg25+L9pr/mVaj9SUq6lHZu/35nx2xnQVRGLrC1KKQqJKRIB0q8GspiHI3J80Q== - -"@esbuild/openbsd-x64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.10.tgz#96eb8992e526717b5272321eaad3e21f3a608e46" - integrity sha512-PxcgvjdSjtgPMiPQrM3pwSaG4kGphP+bLSb+cihuP0LYdZv1epbAIecHVl5sD3npkfYBZ0ZnOjR878I7MdJDFg== - -"@esbuild/sunos-x64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.10.tgz#c16ee1c167f903eaaa6acf7372bee42d5a89c9bc" - integrity sha512-ZkIOtrRL8SEJjr+VHjmW0znkPs+oJXhlJbNwfI37rvgeMtk3sxOQevXPXjmAPZPigVTncvFqLMd+uV0IBSEzqA== - -"@esbuild/win32-arm64@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.10.tgz#7e417d1971dbc7e469b4eceb6a5d1d667b5e3dcc" - integrity sha512-+Sa4oTDbpBfGpl3Hn3XiUe4f8TU2JF7aX8cOfqFYMMjXp6ma6NJDztl5FDG8Ezx0OjwGikIHw+iA54YLDNNVfw== - -"@esbuild/win32-ia32@0.19.10": - version "0.19.10" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.10.tgz#2b52dfec6cd061ecb36171c13bae554888b439e5" - integrity sha512-EOGVLK1oWMBXgfttJdPHDTiivYSjX6jDNaATeNOaCOFEVcfMjtbx7WVQwPSE1eIfCp/CaSF2nSrDtzc4I9f8TQ== - -"@esbuild/win32-x64@0.19.10": - version "0.19.10" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.10.tgz" - integrity sha512-whqLG6Sc70AbU73fFYvuYzaE4MNMBIlR1Y/IrUeOXFrWHxBEjjbZaQ3IXIQS8wJdAzue2GwYZCjOrgrU1oUHoA== +"@esbuild/aix-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" + integrity sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g== + +"@esbuild/android-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" + integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== + +"@esbuild/android-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz#3b488c49aee9d491c2c8f98a909b785870d6e995" + integrity sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w== + +"@esbuild/android-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz#3b1628029e5576249d2b2d766696e50768449f98" + integrity sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg== + +"@esbuild/darwin-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz#6e8517a045ddd86ae30c6608c8475ebc0c4000bb" + integrity sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA== + +"@esbuild/darwin-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz#90ed098e1f9dd8a9381695b207e1cff45540a0d0" + integrity sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA== + +"@esbuild/freebsd-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz#d71502d1ee89a1130327e890364666c760a2a911" + integrity sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw== + +"@esbuild/freebsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz#aa5ea58d9c1dd9af688b8b6f63ef0d3d60cea53c" + integrity sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw== + +"@esbuild/linux-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz#055b63725df678379b0f6db9d0fa85463755b2e5" + integrity sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A== + +"@esbuild/linux-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz#76b3b98cb1f87936fbc37f073efabad49dcd889c" + integrity sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg== + +"@esbuild/linux-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" + integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== + +"@esbuild/linux-loong64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz#a6184e62bd7cdc63e0c0448b83801001653219c5" + integrity sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ== + +"@esbuild/linux-mips64el@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz#d08e39ce86f45ef8fc88549d29c62b8acf5649aa" + integrity sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA== + +"@esbuild/linux-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz#8d252f0b7756ffd6d1cbde5ea67ff8fd20437f20" + integrity sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg== + +"@esbuild/linux-riscv64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz#19f6dcdb14409dae607f66ca1181dd4e9db81300" + integrity sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg== + +"@esbuild/linux-s390x@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz#3c830c90f1a5d7dd1473d5595ea4ebb920988685" + integrity sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ== + +"@esbuild/linux-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz#86eca35203afc0d9de0694c64ec0ab0a378f6fff" + integrity sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw== + +"@esbuild/netbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz#e771c8eb0e0f6e1877ffd4220036b98aed5915e6" + integrity sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ== + +"@esbuild/openbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz#9a795ae4b4e37e674f0f4d716f3e226dd7c39baf" + integrity sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ== + +"@esbuild/sunos-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz#7df23b61a497b8ac189def6e25a95673caedb03f" + integrity sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w== + +"@esbuild/win32-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz#f1ae5abf9ca052ae11c1bc806fb4c0f519bacf90" + integrity sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ== + +"@esbuild/win32-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz#241fe62c34d8e8461cd708277813e1d0ba55ce23" + integrity sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ== + +"@esbuild/win32-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc" + integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ== "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" @@ -236,10 +220,15 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.56.0": - version "8.56.0" - resolved "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz" - integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== +"@eslint/js@8.57.0": + version "8.57.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" + integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== + +"@faker-js/faker@5.5.3": + version "5.5.3" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.5.3.tgz#18e3af6b8eae7984072bbeb0c0858474d7c4cefe" + integrity sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw== "@fortawesome/fontawesome-common-types@6.5.1": version "6.5.1" @@ -272,13 +261,13 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.5.1" -"@humanwhocodes/config-array@^0.11.13": - version "0.11.13" - resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz" - integrity sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ== +"@humanwhocodes/config-array@^0.11.14": + version "0.11.14" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" + integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg== dependencies: - "@humanwhocodes/object-schema" "^2.0.1" - debug "^4.1.1" + "@humanwhocodes/object-schema" "^2.0.2" + debug "^4.3.1" minimatch "^3.0.5" "@humanwhocodes/module-importer@^1.0.1": @@ -286,10 +275,10 @@ resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^2.0.1": - version "2.0.1" - resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz" - integrity sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw== +"@humanwhocodes/object-schema@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" + integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== "@isaacs/cliui@^8.0.2": version "8.0.2" @@ -324,11 +313,6 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.9" -"@jridgewell/resolve-uri@3.1.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz" - integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== - "@jridgewell/resolve-uri@^3.1.0": version "3.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" @@ -339,28 +323,15 @@ resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz" integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== -"@jridgewell/sourcemap-codec@1.4.14": - version "1.4.14" - resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz" - integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== - "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15": version "1.4.15" resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.9": - version "0.3.18" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz" - integrity sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA== - dependencies: - "@jridgewell/resolve-uri" "3.1.0" - "@jridgewell/sourcemap-codec" "1.4.14" - -"@jridgewell/trace-mapping@^0.3.18": - version "0.3.20" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz" - integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q== +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.9": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== dependencies: "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -410,94 +381,94 @@ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@podman-desktop/api@1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@podman-desktop/api/-/api-1.7.0.tgz#d6bc298ab4c9e552e3ec6f6d459eb9a204951655" - integrity sha512-TCs8DUzi2OLYKD4iWo1I9+otfM/g0UOiBdwiy6VQj4ycp8jIFF/BKc902+z52a5mfxmGMLOEnbNS2Yg3aDv9Kw== - -"@rollup/rollup-android-arm-eabi@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.1.tgz#beaf518ee45a196448e294ad3f823d2d4576cf35" - integrity sha512-6vMdBZqtq1dVQ4CWdhFwhKZL6E4L1dV6jUjuBvsavvNJSppzi6dLBbuV+3+IyUREaj9ZFvQefnQm28v4OCXlig== - -"@rollup/rollup-android-arm64@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.1.tgz#6f76cfa759c2d0fdb92122ffe28217181a1664eb" - integrity sha512-Jto9Fl3YQ9OLsTDWtLFPtaIMSL2kwGyGoVCmPC8Gxvym9TCZm4Sie+cVeblPO66YZsYH8MhBKDMGZ2NDxuk/XQ== - -"@rollup/rollup-darwin-arm64@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.1.tgz#9aaefe33a5481d66322d1c62f368171c03eabe2b" - integrity sha512-LtYcLNM+bhsaKAIGwVkh5IOWhaZhjTfNOkGzGqdHvhiCUVuJDalvDxEdSnhFzAn+g23wgsycmZk1vbnaibZwwA== - -"@rollup/rollup-darwin-x64@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.1.tgz#707dcaadcdc6bd3fd6c69f55d9456cd4446306a3" - integrity sha512-KyP/byeXu9V+etKO6Lw3E4tW4QdcnzDG/ake031mg42lob5tN+5qfr+lkcT/SGZaH2PdW4Z1NX9GHEkZ8xV7og== - -"@rollup/rollup-linux-arm-gnueabihf@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.1.tgz#7a4dbbd1dd98731d88a55aefcef0ec4c578fa9c7" - integrity sha512-Yqz/Doumf3QTKplwGNrCHe/B2p9xqDghBZSlAY0/hU6ikuDVQuOUIpDP/YcmoT+447tsZTmirmjgG3znvSCR0Q== - -"@rollup/rollup-linux-arm64-gnu@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.1.tgz#967ba8e6f68a5f21bd00cd97773dcdd6107e94ed" - integrity sha512-u3XkZVvxcvlAOlQJ3UsD1rFvLWqu4Ef/Ggl40WAVCuogf4S1nJPHh5RTgqYFpCOvuGJ7H5yGHabjFKEZGExk5Q== - -"@rollup/rollup-linux-arm64-musl@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.1.tgz#d3a4e1c9f21eef3b9f4e4989f334a519a1341462" - integrity sha512-0XSYN/rfWShW+i+qjZ0phc6vZ7UWI8XWNz4E/l+6edFt+FxoEghrJHjX1EY/kcUGCnZzYYRCl31SNdfOi450Aw== - -"@rollup/rollup-linux-riscv64-gnu@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.1.tgz#415c0533bb752164effd05f5613858e8f6779bc9" - integrity sha512-LmYIO65oZVfFt9t6cpYkbC4d5lKHLYv5B4CSHRpnANq0VZUQXGcCPXHzbCXCz4RQnx7jvlYB1ISVNCE/omz5cw== - -"@rollup/rollup-linux-x64-gnu@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.1.tgz#0983385dd753a2e0ecaddea7a81dd37fea5114f5" - integrity sha512-kr8rEPQ6ns/Lmr/hiw8sEVj9aa07gh1/tQF2Y5HrNCCEPiCBGnBUt9tVusrcBBiJfIt1yNaXN6r1CCmpbFEDpg== - -"@rollup/rollup-linux-x64-musl@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.1.tgz#eb7494ebc5199cbd2e5c38c2b8acbe2603f35e03" - integrity sha512-t4QSR7gN+OEZLG0MiCgPqMWZGwmeHhsM4AkegJ0Kiy6TnJ9vZ8dEIwHw1LcZKhbHxTY32hp9eVCMdR3/I8MGRw== - -"@rollup/rollup-win32-arm64-msvc@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.1.tgz#5bebc66e3a7f82d4b9aa9ff448e7fc13a69656e9" - integrity sha512-7XI4ZCBN34cb+BH557FJPmh0kmNz2c25SCQeT9OiFWEgf8+dL6ZwJ8f9RnUIit+j01u07Yvrsuu1rZGxJCc51g== - -"@rollup/rollup-win32-ia32-msvc@4.9.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.1.tgz#34156ebf8b4de3b20e6497260fe519a30263f8cf" - integrity sha512-yE5c2j1lSWOH5jp+Q0qNL3Mdhr8WuqCNVjc6BxbVfS5cAS6zRmdiw7ktb8GNpDCEUJphILY6KACoFoRtKoqNQg== - -"@rollup/rollup-win32-x64-msvc@4.9.1": - version "4.9.1" - resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.1.tgz" - integrity sha512-PyJsSsafjmIhVgaI1Zdj7m8BB8mMckFah/xbpplObyHfiXzKcI5UOUXRyOdHW7nz4DpMCuzLnF7v5IWHenCwYA== +"@podman-desktop/api@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@podman-desktop/api/-/api-1.8.0.tgz#03205b1b1685c4208a0739d99ac23bb4c749da60" + integrity sha512-z+eTWLQOdhSNxz4KtgMmCaCjghCYPsXoxrMN2NZdDxwoljWmVNB7Q3X/+s1cl7r3hrjwnS6EfZzoYJWQgx9WCQ== + +"@rollup/rollup-android-arm-eabi@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz#b98786c1304b4ff8db3a873180b778649b5dff2b" + integrity sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg== + +"@rollup/rollup-android-arm64@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz#8833679af11172b1bf1ab7cb3bad84df4caf0c9e" + integrity sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q== + +"@rollup/rollup-darwin-arm64@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz#ef02d73e0a95d406e0eb4fd61a53d5d17775659b" + integrity sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g== + +"@rollup/rollup-darwin-x64@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz#3ce5b9bcf92b3341a5c1c58a3e6bcce0ea9e7455" + integrity sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg== + +"@rollup/rollup-linux-arm-gnueabihf@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz#3d3d2c018bdd8e037c6bfedd52acfff1c97e4be4" + integrity sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ== + +"@rollup/rollup-linux-arm64-gnu@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz#5fc8cc978ff396eaa136d7bfe05b5b9138064143" + integrity sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w== + +"@rollup/rollup-linux-arm64-musl@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz#f2ae7d7bed416ffa26d6b948ac5772b520700eef" + integrity sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw== + +"@rollup/rollup-linux-riscv64-gnu@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz#303d57a328ee9a50c85385936f31cf62306d30b6" + integrity sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA== + +"@rollup/rollup-linux-x64-gnu@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz#f672f6508f090fc73f08ba40ff76c20b57424778" + integrity sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA== + +"@rollup/rollup-linux-x64-musl@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz#d2f34b1b157f3e7f13925bca3288192a66755a89" + integrity sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw== + +"@rollup/rollup-win32-arm64-msvc@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz#8ffecc980ae4d9899eb2f9c4ae471a8d58d2da6b" + integrity sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA== + +"@rollup/rollup-win32-ia32-msvc@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz#a7505884f415662e088365b9218b2b03a88fc6f2" + integrity sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw== + +"@rollup/rollup-win32-x64-msvc@4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz#6abd79db7ff8d01a58865ba20a63cfd23d9e2a10" + integrity sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw== "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@sveltejs/vite-plugin-svelte-inspector@^2.0.0-next.0 || ^2.0.0": +"@sveltejs/vite-plugin-svelte-inspector@^2.0.0": version "2.0.0" - resolved "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz" + resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz#365afaa0dd63517838ce4686a3dc3982be348a9b" integrity sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg== dependencies: debug "^4.3.4" -"@sveltejs/vite-plugin-svelte@3.0.1": - version "3.0.1" - resolved "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.1.tgz" - integrity sha512-CGURX6Ps+TkOovK6xV+Y2rn8JKa8ZPUHPZ/NKgCxAmgBrXReavzFl8aOSCj3kQ1xqT7yGJj53hjcV/gqwDAaWA== +"@sveltejs/vite-plugin-svelte@3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.2.tgz#5c33534d07130283cff92304f627010387c11af0" + integrity sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q== dependencies: - "@sveltejs/vite-plugin-svelte-inspector" "^2.0.0-next.0 || ^2.0.0" + "@sveltejs/vite-plugin-svelte-inspector" "^2.0.0" debug "^4.3.4" deepmerge "^4.3.1" kleur "^4.1.5" @@ -515,10 +486,10 @@ lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" -"@testing-library/dom@^9.3.1", "@testing-library/dom@^9.3.3": - version "9.3.3" - resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz" - integrity sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw== +"@testing-library/dom@^9.3.1", "@testing-library/dom@^9.3.4": + version "9.3.4" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" + integrity sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" @@ -529,10 +500,10 @@ lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/jest-dom@^6.2.0": - version "6.2.0" - resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.2.0.tgz#b572bd5cd6b29314487bac7ba393188e4987b4f7" - integrity sha512-+BVQlJ9cmEn5RDMUS8c2+TU6giLvzaHZ8sU/x0Jj7fk+6/46wPdwlgOPcpxS17CjcanBi/3VmGMqVr2rmbUmNw== +"@testing-library/jest-dom@^6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz#38949f6b63722900e2d75ba3c6d9bf8cffb3300e" + integrity sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw== dependencies: "@adobe/css-tools" "^4.3.2" "@babel/runtime" "^7.9.2" @@ -543,17 +514,17 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/svelte@^4.0.5": - version "4.0.5" - resolved "https://registry.npmjs.org/@testing-library/svelte/-/svelte-4.0.5.tgz" - integrity sha512-P7X3mpYv/My4hBZfxVxTFV5KcA+EoWfRCguWP7WQdYj6HMdg/L+LiwG4ocvLe+hupedrC7dwcy85JlxKplJp2g== +"@testing-library/svelte@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@testing-library/svelte/-/svelte-4.1.0.tgz#de6fa34d13d99505e68134ef2acfbafdc03ed39a" + integrity sha512-MJqe7x9WowkiAVdk9mvazEC2ktFZdmK2OqFVoO557PC37aBemQ4ozqdK3yrG34Zg9kuln3qgTVeLSh08e69AMw== dependencies: "@testing-library/dom" "^9.3.1" -"@testing-library/user-event@^14.5.1": - version "14.5.1" - resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz" - integrity sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg== +"@testing-library/user-event@^14.5.2": + version "14.5.2" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" + integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== "@tsconfig/svelte@^5.0.2": version "5.0.2" @@ -565,7 +536,7 @@ resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== -"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.1": +"@types/estree@*", "@types/estree@1.0.5", "@types/estree@^1.0.0", "@types/estree@^1.0.1": version "1.0.5" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== @@ -605,13 +576,35 @@ resolved "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz" integrity sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg== -"@types/node@^18": - version "18.19.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.6.tgz#537beece2c8ad4d9abdaa3b0f428e601eb57dac8" - integrity sha512-X36s5CXMrrJOs2lQCdDF68apW4Rfx9ixYMawlepwmE4Anezv/AV2LSpKD1Ub8DAc+urp5bk0BGZ6NtmBitfnsg== +"@types/node-fetch@^2.6.4": + version "2.6.11" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" + integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + +"@types/node@*", "@types/node@^20": + version "20.11.20" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.20.tgz#f0a2aee575215149a62784210ad88b3a34843659" + integrity sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg== dependencies: undici-types "~5.26.4" +"@types/node@^18.11.18": + version "18.19.18" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.18.tgz#7526471b28828d1fef1f7e4960fb9477e6e4369c" + integrity sha512-80CP7B8y4PzZF0GWx15/gVWRrB5y/bIjNI84NK3cmQJu0WZwvmj2WMA5LcofQFVfLqqCSp545+U2LsrVzX36Zg== + dependencies: + undici-types "~5.26.4" + +"@types/postman-collection@^3.5.10": + version "3.5.10" + resolved "https://registry.yarnpkg.com/@types/postman-collection/-/postman-collection-3.5.10.tgz#c5328dafa246493f820974a65f3d56108a47b5f7" + integrity sha512-l8xAUZNW9MzKWyeWuPgQlnyvpX8beeLqXYZTixr55Figae8/0gFb5l5GcU1y+3yeDmbXdY57cGxdvu+4OGbMdg== + dependencies: + "@types/node" "*" + "@types/pug@^2.0.6": version "2.0.10" resolved "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz" @@ -639,33 +632,16 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@6.15.0": - version "6.15.0" - resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.15.0.tgz" - integrity sha512-j5qoikQqPccq9QoBAupOP+CBu8BaJ8BLjaXSioDISeTZkVO3ig7oSIKh3H+rEpee7xCXtWwSB4KIL5l6hWZzpg== - dependencies: - "@eslint-community/regexpp" "^4.5.1" - "@typescript-eslint/scope-manager" "6.15.0" - "@typescript-eslint/type-utils" "6.15.0" - "@typescript-eslint/utils" "6.15.0" - "@typescript-eslint/visitor-keys" "6.15.0" - debug "^4.3.4" - graphemer "^1.4.0" - ignore "^5.2.4" - natural-compare "^1.4.0" - semver "^7.5.4" - ts-api-utils "^1.0.1" - -"@typescript-eslint/eslint-plugin@^6.16.0": - version "6.16.0" - resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.16.0.tgz" - integrity sha512-O5f7Kv5o4dLWQtPX4ywPPa+v9G+1q1x8mz0Kr0pXUtKsevo+gIJHLkGc8RxaZWtP8RrhwhSNIWThnW42K9/0rQ== +"@typescript-eslint/eslint-plugin@7.0.0", "@typescript-eslint/eslint-plugin@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.0.tgz#62cda0d35bbf601683c6e58cf5d04f0275caca4e" + integrity sha512-M72SJ0DkcQVmmsbqlzc6EJgb/3Oz2Wdm6AyESB4YkGgCxP8u5jt5jn4/OBMPK3HLOxcttZq5xbBBU7e2By4SZQ== dependencies: "@eslint-community/regexpp" "^4.5.1" - "@typescript-eslint/scope-manager" "6.16.0" - "@typescript-eslint/type-utils" "6.16.0" - "@typescript-eslint/utils" "6.16.0" - "@typescript-eslint/visitor-keys" "6.16.0" + "@typescript-eslint/scope-manager" "7.0.0" + "@typescript-eslint/type-utils" "7.0.0" + "@typescript-eslint/utils" "7.0.0" + "@typescript-eslint/visitor-keys" "7.0.0" debug "^4.3.4" graphemer "^1.4.0" ignore "^5.2.4" @@ -680,15 +656,15 @@ dependencies: "@typescript-eslint/utils" "5.59.9" -"@typescript-eslint/parser@^6.16.0": - version "6.16.0" - resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.16.0.tgz" - integrity sha512-H2GM3eUo12HpKZU9njig3DF5zJ58ja6ahj1GoHEHOgQvYxzoFJJEvC1MQ7T2l9Ha+69ZSOn7RTxOdpC/y3ikMw== +"@typescript-eslint/parser@^6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b" + integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== dependencies: - "@typescript-eslint/scope-manager" "6.16.0" - "@typescript-eslint/types" "6.16.0" - "@typescript-eslint/typescript-estree" "6.16.0" - "@typescript-eslint/visitor-keys" "6.16.0" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" debug "^4.3.4" "@typescript-eslint/scope-manager@5.59.9": @@ -699,14 +675,6 @@ "@typescript-eslint/types" "5.59.9" "@typescript-eslint/visitor-keys" "5.59.9" -"@typescript-eslint/scope-manager@6.15.0": - version "6.15.0" - resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.15.0.tgz" - integrity sha512-+BdvxYBltqrmgCNu4Li+fGDIkW9n//NrruzG9X1vBzaNK+ExVXPoGB71kneaVw/Jp+4rH/vaMAGC6JfMbHstVg== - dependencies: - "@typescript-eslint/types" "6.15.0" - "@typescript-eslint/visitor-keys" "6.15.0" - "@typescript-eslint/scope-manager@6.16.0": version "6.16.0" resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.16.0.tgz" @@ -715,23 +683,29 @@ "@typescript-eslint/types" "6.16.0" "@typescript-eslint/visitor-keys" "6.16.0" -"@typescript-eslint/type-utils@6.15.0": - version "6.15.0" - resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.15.0.tgz" - integrity sha512-CnmHKTfX6450Bo49hPg2OkIm/D/TVYV7jO1MCfPYGwf6x3GO0VU8YMO5AYMn+u3X05lRRxA4fWCz87GFQV6yVQ== +"@typescript-eslint/scope-manager@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1" + integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== dependencies: - "@typescript-eslint/typescript-estree" "6.15.0" - "@typescript-eslint/utils" "6.15.0" - debug "^4.3.4" - ts-api-utils "^1.0.1" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" -"@typescript-eslint/type-utils@6.16.0": - version "6.16.0" - resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.16.0.tgz" - integrity sha512-ThmrEOcARmOnoyQfYkHw/DX2SEYBalVECmoldVuH6qagKROp/jMnfXpAU/pAIWub9c4YTxga+XwgAkoA0pxfmg== +"@typescript-eslint/scope-manager@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.0.0.tgz#15ea9abad2b56fc8f5c0b516775f41c86c5c8685" + integrity sha512-IxTStwhNDPO07CCrYuAqjuJ3Xf5MrMaNgbAZPxFXAUpAtwqFxiuItxUaVtP/SJQeCdJjwDGh9/lMOluAndkKeg== dependencies: - "@typescript-eslint/typescript-estree" "6.16.0" - "@typescript-eslint/utils" "6.16.0" + "@typescript-eslint/types" "7.0.0" + "@typescript-eslint/visitor-keys" "7.0.0" + +"@typescript-eslint/type-utils@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.0.0.tgz#a4c7ae114414e09dbbd3c823b5924793f7483252" + integrity sha512-FIM8HPxj1P2G7qfrpiXvbHeHypgo2mFpFGoh5I73ZlqmJOsloSa1x0ZyXCer43++P1doxCgNqIOLqmZR6SOT8g== + dependencies: + "@typescript-eslint/typescript-estree" "7.0.0" + "@typescript-eslint/utils" "7.0.0" debug "^4.3.4" ts-api-utils "^1.0.1" @@ -740,16 +714,21 @@ resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.9.tgz" integrity sha512-uW8H5NRgTVneSVTfiCVffBb8AbwWSKg7qcA4Ot3JI3MPCJGsB4Db4BhvAODIIYE5mNj7Q+VJkK7JxmRhk2Lyjw== -"@typescript-eslint/types@6.15.0": - version "6.15.0" - resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.15.0.tgz" - integrity sha512-yXjbt//E4T/ee8Ia1b5mGlbNj9fB9lJP4jqLbZualwpP2BCQ5is6BcWwxpIsY4XKAhmdv3hrW92GdtJbatC6dQ== - "@typescript-eslint/types@6.16.0": version "6.16.0" resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.16.0.tgz" integrity sha512-hvDFpLEvTJoHutVl87+MG/c5C8I6LOgEx05zExTSJDEVU7hhR3jhV8M5zuggbdFCw98+HhZWPHZeKS97kS3JoQ== +"@typescript-eslint/types@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" + integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== + +"@typescript-eslint/types@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.0.0.tgz#2e5889c7fe3c873fc6dc6420aa77775f17cd5dc6" + integrity sha512-9ZIJDqagK1TTs4W9IyeB2sH/s1fFhN9958ycW8NRTg1vXGzzH5PQNzq6KbsbVGMT+oyyfa17DfchHDidcmf5cg== + "@typescript-eslint/typescript-estree@5.59.9": version "5.59.9" resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.9.tgz" @@ -763,26 +742,41 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@6.15.0": - version "6.15.0" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.15.0.tgz" - integrity sha512-7mVZJN7Hd15OmGuWrp2T9UvqR2Ecg+1j/Bp1jXUEY2GZKV6FXlOIoqVDmLpBiEiq3katvj/2n2mR0SDwtloCew== +"@typescript-eslint/typescript-estree@6.16.0": + version "6.16.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.16.0.tgz" + integrity sha512-VTWZuixh/vr7nih6CfrdpmFNLEnoVBF1skfjdyGnNwXOH1SLeHItGdZDHhhAIzd3ACazyY2Fg76zuzOVTaknGA== dependencies: - "@typescript-eslint/types" "6.15.0" - "@typescript-eslint/visitor-keys" "6.15.0" + "@typescript-eslint/types" "6.16.0" + "@typescript-eslint/visitor-keys" "6.16.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" + minimatch "9.0.3" semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/typescript-estree@6.16.0": - version "6.16.0" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.16.0.tgz" - integrity sha512-VTWZuixh/vr7nih6CfrdpmFNLEnoVBF1skfjdyGnNwXOH1SLeHItGdZDHhhAIzd3ACazyY2Fg76zuzOVTaknGA== +"@typescript-eslint/typescript-estree@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46" + integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== dependencies: - "@typescript-eslint/types" "6.16.0" - "@typescript-eslint/visitor-keys" "6.16.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "9.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/typescript-estree@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.0.tgz#7ce66f2ce068517f034f73fba9029300302fdae9" + integrity sha512-JzsOzhJJm74aQ3c9um/aDryHgSHfaX8SHFIu9x4Gpik/+qxLvxUylhTsO9abcNu39JIdhY2LgYrFxTii3IajLA== + dependencies: + "@typescript-eslint/types" "7.0.0" + "@typescript-eslint/visitor-keys" "7.0.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" @@ -804,20 +798,20 @@ eslint-scope "^5.1.1" semver "^7.3.7" -"@typescript-eslint/utils@6.15.0": - version "6.15.0" - resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.15.0.tgz" - integrity sha512-eF82p0Wrrlt8fQSRL0bGXzK5nWPRV2dYQZdajcfzOD9+cQz9O7ugifrJxclB+xVOvWvagXfqS4Es7vpLP4augw== +"@typescript-eslint/utils@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.0.0.tgz#e43710af746c6ae08484f7afc68abc0212782c7e" + integrity sha512-kuPZcPAdGcDBAyqDn/JVeJVhySvpkxzfXjJq1X1BFSTYo1TTuo4iyb937u457q4K0In84p6u2VHQGaFnv7VYqg== dependencies: "@eslint-community/eslint-utils" "^4.4.0" "@types/json-schema" "^7.0.12" "@types/semver" "^7.5.0" - "@typescript-eslint/scope-manager" "6.15.0" - "@typescript-eslint/types" "6.15.0" - "@typescript-eslint/typescript-estree" "6.15.0" + "@typescript-eslint/scope-manager" "7.0.0" + "@typescript-eslint/types" "7.0.0" + "@typescript-eslint/typescript-estree" "7.0.0" semver "^7.5.4" -"@typescript-eslint/utils@6.16.0", "@typescript-eslint/utils@^6.2.1": +"@typescript-eslint/utils@^6.2.1": version "6.16.0" resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.16.0.tgz" integrity sha512-T83QPKrBm6n//q9mv7oiSvy/Xq/7Hyw9SzSEhMHJwznEmQayfBM87+oAlkNAMEO7/MjIwKyOHgBJbxB0s7gx2A== @@ -838,14 +832,6 @@ "@typescript-eslint/types" "5.59.9" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@6.15.0": - version "6.15.0" - resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.15.0.tgz" - integrity sha512-1zvtdC1a9h5Tb5jU9x3ADNXO9yjP8rXlaoChu0DQX40vf5ACVpYIVIZhIMZ6d5sDXH7vq4dsZBT1fEGj8D2n2w== - dependencies: - "@typescript-eslint/types" "6.15.0" - eslint-visitor-keys "^3.4.1" - "@typescript-eslint/visitor-keys@6.16.0": version "6.16.0" resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.16.0.tgz" @@ -854,82 +840,107 @@ "@typescript-eslint/types" "6.16.0" eslint-visitor-keys "^3.4.1" +"@typescript-eslint/visitor-keys@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47" + integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== + dependencies: + "@typescript-eslint/types" "6.21.0" + eslint-visitor-keys "^3.4.1" + +"@typescript-eslint/visitor-keys@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.0.tgz#83cdadd193ee735fe9ea541f6a2b4d76dfe62081" + integrity sha512-JZP0uw59PRHp7sHQl3aF/lFgwOW2rgNVnXUksj1d932PMita9wFBd3621vHQRDvHwPsSY9FMAAHVc8gTvLYY4w== + dependencies: + "@typescript-eslint/types" "7.0.0" + eslint-visitor-keys "^3.4.1" + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@vitest/coverage-v8@^1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.1.0.tgz" - integrity sha512-kHQRk70vTdXAyQY2C0vKOHPyQD/R6IUzcGdO4vCuyr4alE5Yg1+Sk2jSdjlIrTTXdcNEs+ReWVM09mmSFJpzyQ== +"@vitest/coverage-v8@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz#78ba9e182ff4cd1eba79c45cfafd2edc4c2941ec" + integrity sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg== dependencies: "@ampproject/remapping" "^2.2.1" "@bcoe/v8-coverage" "^0.2.3" debug "^4.3.4" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" - istanbul-lib-source-maps "^4.0.1" + istanbul-lib-source-maps "^5.0.4" istanbul-reports "^3.1.6" magic-string "^0.30.5" - magicast "^0.3.2" + magicast "^0.3.3" picocolors "^1.0.0" std-env "^3.5.0" + strip-literal "^2.0.0" test-exclude "^6.0.0" v8-to-istanbul "^9.2.0" -"@vitest/expect@1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@vitest/expect/-/expect-1.1.0.tgz" - integrity sha512-9IE2WWkcJo2BR9eqtY5MIo3TPmS50Pnwpm66A6neb2hvk/QSLfPXBz2qdiwUOQkwyFuuXEUj5380CbwfzW4+/w== +"@vitest/expect@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.4.0.tgz#d64e17838a20007fecd252397f9b96a1ca81bfb0" + integrity sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA== dependencies: - "@vitest/spy" "1.1.0" - "@vitest/utils" "1.1.0" + "@vitest/spy" "1.4.0" + "@vitest/utils" "1.4.0" chai "^4.3.10" -"@vitest/runner@1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@vitest/runner/-/runner-1.1.0.tgz" - integrity sha512-zdNLJ00pm5z/uhbWF6aeIJCGMSyTyWImy3Fcp9piRGvueERFlQFbUwCpzVce79OLm2UHk9iwaMSOaU9jVHgNVw== +"@vitest/runner@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.4.0.tgz#907c2d17ad5975b70882c25ab7a13b73e5a28da9" + integrity sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg== dependencies: - "@vitest/utils" "1.1.0" + "@vitest/utils" "1.4.0" p-limit "^5.0.0" pathe "^1.1.1" -"@vitest/snapshot@1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.1.0.tgz" - integrity sha512-5O/wyZg09V5qmNmAlUgCBqflvn2ylgsWJRRuPrnHEfDNT6tQpQ8O1isNGgo+VxofISHqz961SG3iVvt3SPK/QQ== +"@vitest/snapshot@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.4.0.tgz#2945b3fb53767a3f4f421919e93edfef2935b8bd" + integrity sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A== dependencies: magic-string "^0.30.5" pathe "^1.1.1" pretty-format "^29.7.0" -"@vitest/spy@1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@vitest/spy/-/spy-1.1.0.tgz" - integrity sha512-sNOVSU/GE+7+P76qYo+VXdXhXffzWZcYIPQfmkiRxaNCSPiLANvQx5Mx6ZURJ/ndtEkUJEpvKLXqAYTKEY+lTg== +"@vitest/spy@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.4.0.tgz#cf953c93ae54885e801cbe6b408a547ae613f26c" + integrity sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q== dependencies: tinyspy "^2.2.0" -"@vitest/utils@1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@vitest/utils/-/utils-1.1.0.tgz" - integrity sha512-z+s510fKmYz4Y41XhNs3vcuFTFhcij2YF7F8VQfMEYAAUfqQh0Zfg7+w9xdgFGhPf3tX3TicAe+8BDITk6ampQ== +"@vitest/utils@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.4.0.tgz#ea6297e0d329f9ff0a106f4e1f6daf3ff6aad3f0" + integrity sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg== dependencies: diff-sequences "^29.6.3" + estree-walker "^3.0.3" loupe "^2.3.7" pretty-format "^29.7.0" +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.3.0: - version "8.3.1" - resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz" - integrity sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw== +acorn-walk@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" + integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== acorn@^8.10.0, acorn@^8.9.0: version "8.11.2" @@ -943,6 +954,13 @@ agent-base@^7.0.2, agent-base@^7.1.0: dependencies: debug "^4.3.4" +agentkeepalive@^4.2.1: + version "4.5.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.5.0.tgz#2673ad1389b3c418c5a20c5d7364f93ca04be923" + integrity sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew== + dependencies: + humanize-ms "^1.2.1" + ajv@^6.12.4: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" @@ -1097,19 +1115,24 @@ assertion-error@^1.1.0: resolved "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +async@3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.2.tgz#2eb7671034bb2194d45d30e31e24ec7e7f9670cd" + integrity sha512-H0E+qZaDEfx/FY4t7iLRv1W2fFI6+pyCeTw1uN20AQPiwqwM6ojPxHxdLv4z8hi2DtnW9BOckSspLucW7pIE5g== + 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== -autoprefixer@^10.4.16: - version "10.4.16" - resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz" - integrity sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ== +autoprefixer@^10.4.19: + version "10.4.19" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f" + integrity sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew== dependencies: - browserslist "^4.21.10" - caniuse-lite "^1.0.30001538" - fraction.js "^4.3.6" + browserslist "^4.23.0" + caniuse-lite "^1.0.30001599" + fraction.js "^4.3.7" normalize-range "^0.1.2" picocolors "^1.0.0" postcss-value-parser "^4.2.0" @@ -1119,10 +1142,10 @@ available-typed-arrays@^1.0.5: resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== -axobject-query@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz" - integrity sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg== +axobject-query@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.0.0.tgz#04a4c90dce33cc5d606c76d6216e3b250ff70dab" + integrity sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw== dependencies: dequal "^2.0.3" @@ -1131,12 +1154,10 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -bidi-js@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2" - integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== - dependencies: - require-from-string "^2.0.2" +base-64@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" + integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== binary-extensions@^2.0.0: version "2.2.0" @@ -1165,13 +1186,13 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" -browserslist@^4.21.10: - version "4.22.2" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz" - integrity sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A== +browserslist@^4.23.0: + version "4.23.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" + integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== dependencies: - caniuse-lite "^1.0.30001565" - electron-to-chromium "^1.4.601" + caniuse-lite "^1.0.30001587" + electron-to-chromium "^1.4.668" node-releases "^2.0.14" update-browserslist-db "^1.0.13" @@ -1212,10 +1233,10 @@ camelcase-css@^2.0.1: resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== -caniuse-lite@^1.0.30001538, caniuse-lite@^1.0.30001565: - version "1.0.30001576" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz" - integrity sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg== +caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599: + version "1.0.30001599" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001599.tgz#571cf4f3f1506df9bf41fcbb6d10d5d017817bce" + integrity sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA== chai@^4.3.10: version "4.3.10" @@ -1255,6 +1276,16 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +charenc@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA== + +charset@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/charset/-/charset-1.0.1.tgz#8d59546c355be61049a8fa9164747793319852bd" + integrity sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg== + check-error@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz" @@ -1262,10 +1293,10 @@ check-error@^1.0.3: dependencies: get-func-name "^2.0.2" -chokidar@^3.5.3: - version "3.5.3" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== +chokidar@^3.4.1, chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== dependencies: anymatch "~3.1.2" braces "~3.0.2" @@ -1367,6 +1398,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypt@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== + css-tree@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz" @@ -1407,7 +1443,7 @@ date-fns@^2.30.0: dependencies: "@babel/runtime" "^7.21.0" -debug@4, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -1509,6 +1545,14 @@ diff-sequences@^29.6.3: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz" integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== +digest-fetch@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/digest-fetch/-/digest-fetch-1.3.0.tgz#898e69264d00012a23cf26e8a3e40320143fc661" + integrity sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA== + dependencies: + base-64 "^0.1.0" + md5 "^2.3.0" + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" @@ -1550,10 +1594,10 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -electron-to-chromium@^1.4.601: - version "1.4.624" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.624.tgz" - integrity sha512-w9niWuheXjz23vezH3w90n9KKcHe0UkhTfJ+rXJkuGGogHyQbQ7KS1x0a8ER4LbI3ljFS/gqxKh1TidNXDMHOg== +electron-to-chromium@^1.4.668: + version "1.4.689" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.689.tgz#94fe370b800d978b606a2b4c0c5db5c8c98db4f2" + integrity sha512-GatzRKnGPS1go29ep25reM94xxd1Wj8ritU0yRhCJ/tr1Bg8gKnm6R9O/yPOhGQBoLMZ9ezfrpghNaTw97C/PQ== emoji-regex@^8.0.0: version "8.0.0" @@ -1668,34 +1712,34 @@ es6-promise@^3.1.2: resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz" integrity sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg== -esbuild@^0.19.3: - version "0.19.10" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.19.10.tgz" - integrity sha512-S1Y27QGt/snkNYrRcswgRFqZjaTG5a5xM3EQo97uNBnH505pdzSNe/HLBq1v0RO7iK/ngdbhJB6mDAp0OK+iUA== +esbuild@^0.20.1: + version "0.20.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.20.2.tgz#9d6b2386561766ee6b5a55196c6d766d28c87ea1" + integrity sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g== optionalDependencies: - "@esbuild/aix-ppc64" "0.19.10" - "@esbuild/android-arm" "0.19.10" - "@esbuild/android-arm64" "0.19.10" - "@esbuild/android-x64" "0.19.10" - "@esbuild/darwin-arm64" "0.19.10" - "@esbuild/darwin-x64" "0.19.10" - "@esbuild/freebsd-arm64" "0.19.10" - "@esbuild/freebsd-x64" "0.19.10" - "@esbuild/linux-arm" "0.19.10" - "@esbuild/linux-arm64" "0.19.10" - "@esbuild/linux-ia32" "0.19.10" - "@esbuild/linux-loong64" "0.19.10" - "@esbuild/linux-mips64el" "0.19.10" - "@esbuild/linux-ppc64" "0.19.10" - "@esbuild/linux-riscv64" "0.19.10" - "@esbuild/linux-s390x" "0.19.10" - "@esbuild/linux-x64" "0.19.10" - "@esbuild/netbsd-x64" "0.19.10" - "@esbuild/openbsd-x64" "0.19.10" - "@esbuild/sunos-x64" "0.19.10" - "@esbuild/win32-arm64" "0.19.10" - "@esbuild/win32-ia32" "0.19.10" - "@esbuild/win32-x64" "0.19.10" + "@esbuild/aix-ppc64" "0.20.2" + "@esbuild/android-arm" "0.20.2" + "@esbuild/android-arm64" "0.20.2" + "@esbuild/android-x64" "0.20.2" + "@esbuild/darwin-arm64" "0.20.2" + "@esbuild/darwin-x64" "0.20.2" + "@esbuild/freebsd-arm64" "0.20.2" + "@esbuild/freebsd-x64" "0.20.2" + "@esbuild/linux-arm" "0.20.2" + "@esbuild/linux-arm64" "0.20.2" + "@esbuild/linux-ia32" "0.20.2" + "@esbuild/linux-loong64" "0.20.2" + "@esbuild/linux-mips64el" "0.20.2" + "@esbuild/linux-ppc64" "0.20.2" + "@esbuild/linux-riscv64" "0.20.2" + "@esbuild/linux-s390x" "0.20.2" + "@esbuild/linux-x64" "0.20.2" + "@esbuild/netbsd-x64" "0.20.2" + "@esbuild/openbsd-x64" "0.20.2" + "@esbuild/sunos-x64" "0.20.2" + "@esbuild/win32-arm64" "0.20.2" + "@esbuild/win32-ia32" "0.20.2" + "@esbuild/win32-x64" "0.20.2" escalade@^3.1.1: version "3.1.1" @@ -1805,10 +1849,10 @@ eslint-plugin-redundant-undefined@^1.0.0: dependencies: "@typescript-eslint/utils" "^6.2.1" -eslint-plugin-sonarjs@^0.23.0: - version "0.23.0" - resolved "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.23.0.tgz" - integrity sha512-z44T3PBf9W7qQ/aR+NmofOTyg6HLhSEZOPD4zhStqBpLoMp8GYhFksuUBnCxbnf1nfISpKBVkQhiBLFI/F4Wlg== +eslint-plugin-sonarjs@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.24.0.tgz#b594ccb9b1d6123edd3c3fe3a45b4392e14932d7" + integrity sha512-87zp50mbbNrSTuoEOebdRQBPa0mdejA5UEjyuScyIw8hEpEjfWP89Qhkq5xVZfVyVSRQKZc9alVm7yRKQvvUmg== eslint-scope@^5.1.1: version "5.1.1" @@ -1826,26 +1870,21 @@ eslint-scope@^7.2.2: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1: - version "3.4.1" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz" - integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA== - -eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8.56.0: - version "8.56.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz" - integrity sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ== +eslint@^8.57.0: + version "8.57.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" + integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "8.56.0" - "@humanwhocodes/config-array" "^0.11.13" + "@eslint/js" "8.57.0" + "@humanwhocodes/config-array" "^0.11.14" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" "@ungap/structured-clone" "^1.2.0" @@ -1925,6 +1964,11 @@ esutils@^2.0.2: resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + execa@^8.0.1: version "8.0.1" resolved "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz" @@ -1940,12 +1984,17 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" +faker@5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/faker/-/faker-5.5.3.tgz#c57974ee484431b25205c2c8dc09fda861e51e0e" + integrity sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.1: +fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.1: version "3.3.2" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz" integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== @@ -1980,10 +2029,15 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -filesize@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.0.tgz#846f5cd8d16e073c5d6767651a8264f6149183cd" - integrity sha512-GTLKYyBSDz3nPhlLVPjPWZCnhkd9TrrRArNcy8Z+J2cqScB7h2McAzR6NBX6nYOoWafql0roY8hrocxnZBv9CQ== +file-type@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" + integrity sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA== + +filesize@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.1.tgz#eb98ce885aa73741199748e70e5b7339cc22c5ff" + integrity sha512-L0cdwZrKlwZQkMSFnCflJ6J2Y+5egO/p3vgRSDQGxQt++QbUZe5gMbRO6kg6gzwQDPvq2Fk9AmoxUNfZ5gdqaQ== fill-range@^7.0.1: version "7.0.1" @@ -2028,6 +2082,11 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -2037,9 +2096,17 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -fraction.js@^4.3.6: +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + +fraction.js@^4.3.7: version "4.3.7" - resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== fs.realpath@^1.0.0: @@ -2152,7 +2219,7 @@ glob@^10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" -glob@^7.1.3, glob@^7.1.4: +glob@^7.0.0, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -2280,6 +2347,11 @@ http-proxy-agent@^7.0.0: agent-base "^7.1.0" debug "^4.3.4" +http-reasons@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/http-reasons/-/http-reasons-0.1.0.tgz#a953ca670078669dde142ce899401b9d6e85d3b4" + integrity sha512-P6kYh0lKZ+y29T2Gqz+RlC9WBLhKe8kDmcJ+A+611jFfxdPsbMRQ5aNmFRM3lENqFkK+HTTL+tlQviAiv0AbLQ== + https-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz#e2645b846b90e96c6e6f347fb5b2e41f1590b09b" @@ -2298,6 +2370,13 @@ humanize-duration@^3.31.0: resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.31.0.tgz#a0384d22555024cd17e6e9f8561540d37756bf4c" integrity sha512-fRrehgBG26NNZysRlTq1S+HPtDpp3u+Jzdc/d5A4cEzOD86YLAkDaJyJg8krSdCi7CJ+s7ht3fwRj8Dl+Btd0w== +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -2305,12 +2384,7 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ignore@^5.2.0: - version "5.2.4" - resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz" - integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== - -ignore@^5.2.4: +ignore@^5.2.0, ignore@^5.2.4: version "5.3.0" resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== @@ -2346,6 +2420,11 @@ inherits@2: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + internal-slot@^1.0.4, internal-slot@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz" @@ -2355,6 +2434,11 @@ internal-slot@^1.0.4, internal-slot@^1.0.5: has "^1.0.3" side-channel "^1.0.4" +interpret@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz" @@ -2394,6 +2478,11 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-buffer@~1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz" @@ -2556,12 +2645,7 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -istanbul-lib-coverage@^3.0.0: - version "3.2.0" - resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz" - integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== - -istanbul-lib-coverage@^3.2.2: +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: version "3.2.2" resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== @@ -2575,14 +2659,14 @@ istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== +istanbul-lib-source-maps@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz#1947003c72a91b6310efeb92d2a91be8804d92c2" + integrity sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw== dependencies: + "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" istanbul-reports@^3.1.6: version "3.1.6" @@ -2611,6 +2695,11 @@ js-tokens@^4.0.0: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-tokens@^8.0.2: + version "8.0.3" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-8.0.3.tgz#1c407ec905643603b38b6be6977300406ec48775" + integrity sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw== + js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz" @@ -2618,12 +2707,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -jsdom@^23.2.0: - version "23.2.0" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-23.2.0.tgz#08083220146d41c467efa1c6969f02b525ba6c1d" - integrity sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA== +jsdom@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-24.0.0.tgz#e2dc04e4c79da368481659818ee2b0cd7c39007c" + integrity sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A== dependencies: - "@asamuzakjp/dom-selector" "^2.0.1" cssstyle "^4.0.1" data-urls "^5.0.0" decimal.js "^10.4.3" @@ -2632,6 +2720,7 @@ jsdom@^23.2.0: http-proxy-agent "^7.0.0" https-proxy-agent "^7.0.2" is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.7" parse5 "^7.1.2" rrweb-cssom "^0.6.0" saxes "^6.0.0" @@ -2695,6 +2784,11 @@ lines-and-columns@^1.1.6: resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +liquid-json@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/liquid-json/-/liquid-json-0.3.1.tgz#9155a18136d8a6b2615e5f16f9a2448ab6b50eea" + integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ== + local-pkg@^0.5.0: version "0.5.0" resolved "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz" @@ -2730,7 +2824,7 @@ lodash.merge@^4.6.2: resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.15, lodash@^4.17.21: +lodash@4.17.21, lodash@^4.17.15, lodash@^4.17.21: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -2773,13 +2867,13 @@ magic-string@^0.30.4, magic-string@^0.30.5: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" -magicast@^0.3.2: - version "0.3.2" - resolved "https://registry.npmjs.org/magicast/-/magicast-0.3.2.tgz" - integrity sha512-Fjwkl6a0syt9TFN0JSYpOybxiMCkYNEeOTnOTNRbjphirLakznZXAqrXgj/7GG3D1dvETONNwrBfinvAbpunDg== +magicast@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.3.tgz#a15760f982deec9dabc5f314e318d7c6bddcb27b" + integrity sha512-ZbrP1Qxnpoes8sz47AM0z08U+jW6TyRgZzcWy3Ma3vDhJttwMwAFDMMQFobwdBxByBD46JYmxRzeF7w2+wJEuw== dependencies: - "@babel/parser" "^7.23.3" - "@babel/types" "^7.23.3" + "@babel/parser" "^7.23.6" + "@babel/types" "^7.23.6" source-map-js "^1.0.2" make-dir@^4.0.0: @@ -2794,6 +2888,15 @@ marked@^5.1.2: resolved "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz" integrity sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg== +md5@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" + integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g== + dependencies: + charenc "0.0.2" + crypt "0.0.2" + is-buffer "~1.1.6" + mdn-data@2.0.30: version "2.0.30" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz" @@ -2817,12 +2920,31 @@ micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.2" picomatch "^2.3.1" +mime-db@1.48.0: + version "1.48.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" + integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== + 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: +mime-format@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mime-format/-/mime-format-2.0.1.tgz#1274876d58bc803332427a515f5f7036e07b9413" + integrity sha512-XxU3ngPbEnrYnNbIX+lYSaYg0M01v6p2ntd2YaFksTu0vayaw5OJvbdRyWs07EYRlLED5qadUZ+xo+XhOvFhwg== + dependencies: + charset "^1.0.0" + +mime-types@2.1.31: + version "2.1.31" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.31.tgz#a00d76b74317c61f9c2db2218b8e9f8e9c5c9e6b" + integrity sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg== + dependencies: + mime-db "1.48.0" + +mime-types@2.1.35, 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== @@ -2885,11 +3007,26 @@ moment@^2.30.1: resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== +mri@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" + integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== + ms@2.1.2, ms@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.0.0: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + mz@^2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz" @@ -2909,6 +3046,18 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-releases@^2.0.14: version "2.0.14" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz" @@ -2931,6 +3080,11 @@ npm-run-path@^5.1.0: dependencies: path-key "^4.0.0" +nwsapi@^2.2.7: + version "2.2.7" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" + integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== + object-assign@^4.0.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" @@ -3016,6 +3170,21 @@ onetime@^6.0.0: dependencies: mimic-fn "^4.0.0" +openai@^4.29.2: + version "4.29.2" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.29.2.tgz#45e83cb49dbd052626637b267c749785a24f411c" + integrity sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + digest-fetch "^1.3.0" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + web-streams-polyfill "^3.2.1" + optionator@^0.9.3: version "0.9.3" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz" @@ -3101,6 +3270,14 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +path@0.12.7: + version "0.12.7" + resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" + integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q== + dependencies: + process "^0.11.1" + util "^0.10.3" + pathe@^1.1.0, pathe@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz" @@ -3173,10 +3350,10 @@ postcss-load-config@^4.0.1: lilconfig "^3.0.0" yaml "^2.3.4" -postcss-load-config@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-5.0.2.tgz" - integrity sha512-Q8QR3FYbqOKa0bnC1UQ2bFq9/ulHX5Bi34muzitMr8aDtUelO5xKeJEYC/5smE0jNE9zdB/NBnOwXKexELbRlw== +postcss-load-config@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-5.0.3.tgz#f4927637d907de900c4828615077646844545820" + integrity sha512-90pBBI5apUVruIEdCxZic93Wm+i9fTrp7TXbgdUCH+/L+2WnfpITSpq5dFU/IPvbv7aNiMlQISpUkAm3fEcvgQ== dependencies: lilconfig "^3.0.0" yaml "^2.3.4" @@ -3209,33 +3386,88 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.23, postcss@^8.4.32: - version "8.4.32" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz" - integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw== +postcss@^8.4.23, postcss@^8.4.36, postcss@^8.4.38: + version "8.4.38" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== dependencies: nanoid "^3.3.7" picocolors "^1.0.0" - source-map-js "^1.0.2" + source-map-js "^1.2.0" -postcss@^8.4.33: - version "8.4.33" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz" - integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg== +postman-code-generators@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/postman-code-generators/-/postman-code-generators-1.9.0.tgz#3abba8a62b67a73fb8f2c03f7b2b56edf02be520" + integrity sha512-ZM4H7cU1dNUuMPw9CsEoQ7aONl/n8bpSEunZcvzyJd1WtLNj5ktGBGOlDtbTo773dZy5CiVrugdCdt0jhdnUOA== dependencies: - nanoid "^3.3.7" - picocolors "^1.0.0" - source-map-js "^1.0.2" + async "3.2.2" + lodash "4.17.21" + path "0.12.7" + postman-collection "4.0.0" + shelljs "0.8.5" + +postman-collection@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postman-collection/-/postman-collection-4.0.0.tgz#4a72c60f0d9725656d0e02d44e294e1c22ef3ffa" + integrity sha512-vDrXG/dclSu6RMqPqBz4ZqoQBwcj/a80sJYsQZmzWJ6dWgXiudPhwu6Vm3C1Hy7zX5W8A6am1Z6vb/TB4eyURA== + dependencies: + faker "5.5.3" + file-type "3.9.0" + http-reasons "0.1.0" + iconv-lite "0.6.3" + liquid-json "0.3.1" + lodash "4.17.21" + mime-format "2.0.1" + mime-types "2.1.31" + postman-url-encoder "3.0.1" + semver "7.3.5" + uuid "8.3.2" + +postman-collection@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/postman-collection/-/postman-collection-4.4.0.tgz#6acb6e3796fcd9f6ac5a94e6894185e42387d7da" + integrity sha512-2BGDFcUwlK08CqZFUlIC8kwRJueVzPjZnnokWPtJCd9f2J06HBQpGL7t2P1Ud1NEsK9NHq9wdipUhWLOPj5s/Q== + dependencies: + "@faker-js/faker" "5.5.3" + file-type "3.9.0" + http-reasons "0.1.0" + iconv-lite "0.6.3" + liquid-json "0.3.1" + lodash "4.17.21" + mime-format "2.0.1" + mime-types "2.1.35" + postman-url-encoder "3.0.5" + semver "7.5.4" + uuid "8.3.2" + +postman-url-encoder@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/postman-url-encoder/-/postman-url-encoder-3.0.1.tgz#a7434a169567c45f022dc435d46a86d71de6a8aa" + integrity sha512-dMPqXnkDlstM2Eya+Gw4MIGWEan8TzldDcUKZIhZUsJ/G5JjubfQPhFhVWKzuATDMvwvrWbSjF+8VmAvbu6giw== + dependencies: + punycode "^2.1.1" + +postman-url-encoder@3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/postman-url-encoder/-/postman-url-encoder-3.0.5.tgz#af2efee3bb7644e2b059d8a78bc8070fae0467a5" + integrity sha512-jOrdVvzUXBC7C+9gkIkpDJ3HIxOHTIqjpQ4C1EMt1ZGeMvSEpbFCKq23DEfgsj46vMnDgyQf+1ZLp2Wm+bKSsA== + dependencies: + punycode "^2.1.1" prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz" - integrity sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw== +prettier-plugin-svelte@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.2.tgz#df576c8a92088dc0aaec8e27fce8a7d9683de93c" + integrity sha512-ZzzE/wMuf48/1+Lf2Ffko0uDa6pyCfgHV6+uAhtg2U0AAXGrhCSW88vEJNAkAxW5qyrFY1y1zZ4J8TgHrjW++Q== + +prettier@^3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" + integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== pretty-format@^27.0.2: version "27.5.1" @@ -3255,6 +3487,11 @@ pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +process@^0.11.1: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" @@ -3304,6 +3541,13 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw== + dependencies: + resolve "^1.1.6" + redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" @@ -3331,11 +3575,6 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - requireindex@~1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz" @@ -3356,7 +3595,7 @@ resolve-pkg-maps@^1.0.0: resolved "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz" integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== -resolve@^1.1.7, resolve@^1.22.4: +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.22.2, resolve@^1.22.4: version "1.22.8" resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz" integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== @@ -3365,15 +3604,6 @@ resolve@^1.1.7, resolve@^1.22.4: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.22.2: - version "1.22.2" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz" - integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== - dependencies: - is-core-module "^2.11.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - reusify@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz" @@ -3393,24 +3623,26 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^4.2.0: - version "4.9.1" - resolved "https://registry.npmjs.org/rollup/-/rollup-4.9.1.tgz" - integrity sha512-pgPO9DWzLoW/vIhlSoDByCzcpX92bKEorbgXuZrqxByte3JFk2xSW2JEeAcyLc9Ru9pqcNNW+Ob7ntsk2oT/Xw== +rollup@^4.13.0: + version "4.13.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.13.0.tgz#dd2ae144b4cdc2ea25420477f68d4937a721237a" + integrity sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg== + dependencies: + "@types/estree" "1.0.5" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.9.1" - "@rollup/rollup-android-arm64" "4.9.1" - "@rollup/rollup-darwin-arm64" "4.9.1" - "@rollup/rollup-darwin-x64" "4.9.1" - "@rollup/rollup-linux-arm-gnueabihf" "4.9.1" - "@rollup/rollup-linux-arm64-gnu" "4.9.1" - "@rollup/rollup-linux-arm64-musl" "4.9.1" - "@rollup/rollup-linux-riscv64-gnu" "4.9.1" - "@rollup/rollup-linux-x64-gnu" "4.9.1" - "@rollup/rollup-linux-x64-musl" "4.9.1" - "@rollup/rollup-win32-arm64-msvc" "4.9.1" - "@rollup/rollup-win32-ia32-msvc" "4.9.1" - "@rollup/rollup-win32-x64-msvc" "4.9.1" + "@rollup/rollup-android-arm-eabi" "4.13.0" + "@rollup/rollup-android-arm64" "4.13.0" + "@rollup/rollup-darwin-arm64" "4.13.0" + "@rollup/rollup-darwin-x64" "4.13.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.13.0" + "@rollup/rollup-linux-arm64-gnu" "4.13.0" + "@rollup/rollup-linux-arm64-musl" "4.13.0" + "@rollup/rollup-linux-riscv64-gnu" "4.13.0" + "@rollup/rollup-linux-x64-gnu" "4.13.0" + "@rollup/rollup-linux-x64-musl" "4.13.0" + "@rollup/rollup-win32-arm64-msvc" "4.13.0" + "@rollup/rollup-win32-ia32-msvc" "4.13.0" + "@rollup/rollup-win32-x64-msvc" "4.13.0" fsevents "~2.3.2" rrweb-cssom@^0.6.0: @@ -3432,6 +3664,13 @@ rxjs@^7.8.1: dependencies: tslib "^2.1.0" +sade@^1.7.4: + version "1.8.1" + resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" + integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A== + dependencies: + mri "^1.1.0" + safe-array-concat@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz" @@ -3466,6 +3705,11 @@ sander@^0.5.0: mkdirp "^0.5.1" rimraf "^2.5.2" +sax@^1.2.4: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" + integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA== + saxes@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" @@ -3473,18 +3717,25 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -semver@^6.3.1: - version "6.3.1" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" -semver@^7.3.7, semver@^7.5.3, semver@^7.5.4: +semver@7.5.4, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4: version "7.5.4" resolved "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + set-function-length@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz" @@ -3521,6 +3772,15 @@ shell-quote@^1.8.1: resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz" integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== +shelljs@0.8.5: + version "0.8.5" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" + integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz" @@ -3540,10 +3800,10 @@ signal-exit@^4.0.1, signal-exit@^4.1.0: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -simple-git@^3.22.0: - version "3.22.0" - resolved "https://registry.npmjs.org/simple-git/-/simple-git-3.22.0.tgz" - integrity sha512-6JujwSs0ac82jkGjMHiCnTifvf1crOiY/+tfs/Pqih6iow7VrpNKRRNdWm6RtaXpvvv/JGNYhlUtLhGFqHF+Yw== +simple-git@^3.23.0: + version "3.23.0" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.23.0.tgz#e91d2e8c1dec234c48c57c424aa32b8f44e5e9d4" + integrity sha512-P9ggTW8vb/21CAL/AmnACAhqBDfnqSSZVpV7WuFtsFR9HLunf5IqQvk+OXAQTfkcZep8pKnt3DV3o7w3TegEkQ== dependencies: "@kwsites/file-exists" "^1.1.1" "@kwsites/promise-deferred" "^1.1.1" @@ -3564,15 +3824,10 @@ sorcery@^0.11.0: minimist "^1.2.0" sander "^0.5.0" -source-map-js@^1.0.1, source-map-js@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== - -source-map@^0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== spawn-command@0.0.2: version "0.0.2" @@ -3677,12 +3932,12 @@ strip-json-comments@^3.1.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strip-literal@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz" - integrity sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg== +strip-literal@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.0.0.tgz#5d063580933e4e03ebb669b12db64d2200687527" + integrity sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA== dependencies: - acorn "^8.10.0" + js-tokens "^8.0.2" sucrase@^3.32.0: version "3.35.0" @@ -3723,10 +3978,24 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -svelte-fa@^3.0.4: - version "3.0.4" - resolved "https://registry.npmjs.org/svelte-fa/-/svelte-fa-3.0.4.tgz" - integrity sha512-y04vEuAoV1wwVDItSCzPW7lzT6v1bj/y1p+W1V+NtIMpQ+8hj8MBkx7JFD7JHSnalPU1QiI8BVfguqheEA3nPg== +svelte-check@^3.6.8: + version "3.6.8" + resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-3.6.8.tgz#6621ad65bf729cafc927a8c5a54f5534c4c58e55" + integrity sha512-rhXU7YCDtL+lq2gCqfJDXKTxJfSsCgcd08d7VWBFxTw6IWIbMWSaASbAOD3N0VV9TYSSLUqEBiratLd8WxAJJA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.17" + chokidar "^3.4.1" + fast-glob "^3.2.7" + import-fresh "^3.2.1" + picocolors "^1.0.0" + sade "^1.7.4" + svelte-preprocess "^5.1.3" + typescript "^5.0.3" + +svelte-fa@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/svelte-fa/-/svelte-fa-4.0.2.tgz#f73aab661bf1758d726f06db321f0ffb8e2f40d6" + integrity sha512-lza8Jfii6jcpMQB73mBStONxaLfZsUS+rKJ/hH6WxsHUd+g68+oHIL9yQTk4a0uY9HQk78T/CPvQnED0msqJfg== svelte-hmr@^0.15.3: version "0.15.3" @@ -3752,17 +4021,18 @@ svelte-preprocess@^5.1.3: sorcery "^0.11.0" strip-indent "^3.0.0" -svelte@4.2.8: - version "4.2.8" - resolved "https://registry.npmjs.org/svelte/-/svelte-4.2.8.tgz" - integrity sha512-hU6dh1MPl8gh6klQZwK/n73GiAHiR95IkFsesLPbMeEZi36ydaXL/ZAb4g9sayT0MXzpxyZjR28yderJHxcmYA== +svelte@4.2.12: + version "4.2.12" + resolved "https://registry.yarnpkg.com/svelte/-/svelte-4.2.12.tgz#13d98d2274d24d3ad216c8fdc801511171c70bb1" + integrity sha512-d8+wsh5TfPwqVzbm4/HCXC783/KPHV60NvwitJnyTA5lWn1elhXMNWhXGCJ7PwPa8qFUnyJNIyuIRt2mT0WMug== dependencies: "@ampproject/remapping" "^2.2.1" "@jridgewell/sourcemap-codec" "^1.4.15" "@jridgewell/trace-mapping" "^0.3.18" + "@types/estree" "^1.0.1" acorn "^8.9.0" aria-query "^5.3.0" - axobject-query "^3.2.1" + axobject-query "^4.0.0" code-red "^1.0.3" css-tree "^2.3.1" estree-walker "^3.0.3" @@ -3776,10 +4046,10 @@ symbol-tree@^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== -tailwindcss@^3.4.0: - version "3.4.0" - resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz" - integrity sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA== +tailwindcss@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.1.tgz#f512ca5d1dd4c9503c7d3d28a968f1ad8f5c839d" + integrity sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA== dependencies: "@alloc/quick-lru" "^5.2.0" arg "^5.0.2" @@ -3847,10 +4117,10 @@ tinybench@^2.5.1: resolved "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz" integrity sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg== -tinypool@^0.8.1: - version "0.8.1" - resolved "https://registry.npmjs.org/tinypool/-/tinypool-0.8.1.tgz" - integrity sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg== +tinypool@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.2.tgz#84013b03dc69dacb322563a475d4c0a9be00f82a" + integrity sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ== tinyspy@^2.2.0: version "2.2.0" @@ -3886,6 +4156,11 @@ tr46@^5.0.0: dependencies: punycode "^2.3.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz" @@ -3992,10 +4267,10 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" -typescript@5.3.3: - version "5.3.3" - resolved "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz" - integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== +typescript@5.4.3, typescript@^5.0.3: + version "5.4.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.3.tgz#5c6fedd4c87bee01cd7a528a30145521f8e0feff" + integrity sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg== ufo@^1.3.0: version "1.3.2" @@ -4050,6 +4325,18 @@ util-deprecate@^1.0.2: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +util@^0.10.3: + version "0.10.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" + integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== + dependencies: + inherits "2.0.3" + +uuid@8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-to-istanbul@^9.2.0: version "9.2.0" resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz" @@ -4059,10 +4346,10 @@ v8-to-istanbul@^9.2.0: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^2.0.0" -vite-node@1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/vite-node/-/vite-node-1.1.0.tgz" - integrity sha512-jV48DDUxGLEBdHCQvxL1mEh7+naVy+nhUUUaPAZLd3FJgXuxQiewHcfeZebbJ6onDqNGkP4r3MhQ342PRlG81Q== +vite-node@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.4.0.tgz#265529d60570ca695ceb69391f87f92847934ad8" + integrity sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw== dependencies: cac "^6.7.14" debug "^4.3.4" @@ -4070,14 +4357,14 @@ vite-node@1.1.0: picocolors "^1.0.0" vite "^5.0.0" -vite@^5.0.0, vite@^5.0.10: - version "5.0.10" - resolved "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz" - integrity sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw== +vite@^5.0.0, vite@^5.2.3: + version "5.2.3" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.3.tgz#198efc2fd4d80eac813b146a68a4b0dbde884fc2" + integrity sha512-+i1oagbvkVIhEy9TnEV+fgXsng13nZM90JQbrcPrf6DvW2mXARlz+DK7DLiDP+qeKoD1FCVx/1SpFL1CLq9Mhw== dependencies: - esbuild "^0.19.3" - postcss "^8.4.32" - rollup "^4.2.0" + esbuild "^0.20.1" + postcss "^8.4.36" + rollup "^4.13.0" optionalDependencies: fsevents "~2.3.3" @@ -4086,18 +4373,17 @@ vitefu@^0.2.5: resolved "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz" integrity sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q== -vitest@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/vitest/-/vitest-1.1.0.tgz" - integrity sha512-oDFiCrw7dd3Jf06HoMtSRARivvyjHJaTxikFxuqJjO76U436PqlVw1uLn7a8OSPrhSfMGVaRakKpA2lePdw79A== - dependencies: - "@vitest/expect" "1.1.0" - "@vitest/runner" "1.1.0" - "@vitest/snapshot" "1.1.0" - "@vitest/spy" "1.1.0" - "@vitest/utils" "1.1.0" - acorn-walk "^8.3.0" - cac "^6.7.14" +vitest@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.4.0.tgz#f5c812aaf5023818b89b7fc667fa45327396fece" + integrity sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw== + dependencies: + "@vitest/expect" "1.4.0" + "@vitest/runner" "1.4.0" + "@vitest/snapshot" "1.4.0" + "@vitest/spy" "1.4.0" + "@vitest/utils" "1.4.0" + acorn-walk "^8.3.2" chai "^4.3.10" debug "^4.3.4" execa "^8.0.1" @@ -4106,11 +4392,11 @@ vitest@^1.1.0: pathe "^1.1.1" picocolors "^1.0.0" std-env "^3.5.0" - strip-literal "^1.3.0" + strip-literal "^2.0.0" tinybench "^2.5.1" - tinypool "^0.8.1" + tinypool "^0.8.2" vite "^5.0.0" - vite-node "1.1.0" + vite-node "1.4.0" why-is-node-running "^2.2.2" w3c-xmlserializer@^5.0.0: @@ -4120,6 +4406,21 @@ w3c-xmlserializer@^5.0.0: dependencies: xml-name-validator "^5.0.0" +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + +web-streams-polyfill@^3.2.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.2.tgz#32e26522e05128203a7de59519be3c648004343b" + integrity sha512-3pRGuxRF5gpuZc0W+EpwQRmCD7gRqcDOMt688KmdlDAgAyaB1XlN0zq2njfDNm44XVdIouE7pZ6GzbdyH47uIQ== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" @@ -4145,6 +4446,14 @@ whatwg-url@^14.0.0: tr46 "^5.0.0" webidl-conversions "^7.0.0" +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz" @@ -4220,6 +4529,13 @@ ws@^8.16.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== +xml-js@^1.6.11: + version "1.6.11" + resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" + integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== + dependencies: + sax "^1.2.4" + xml-name-validator@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"