diff --git a/.env.example b/.env.example index 05854cd0..2d782fce 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,8 @@ OPENAI_API_KEY='' # DO NOT TRACK SCARF_NO_ANALYTICS=true DO_NOT_TRACK=true +ANONYMIZED_TELEMETRY=false # Use locally bundled version of the LiteLLM cost map json # to avoid repetitive startup connections -LITELLM_LOCAL_MODEL_COST_MAP="True" +LITELLM_LOCAL_MODEL_COST_MAP="True" \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ebc19589..cea095ea 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,6 +4,7 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:svelte/recommended', + 'plugin:cypress/recommended', 'prettier' ], parser: '@typescript-eslint/parser', diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 43866613..10958583 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -24,6 +24,9 @@ assignees: '' ## Environment +- **Open WebUI Version:** [e.g., 0.1.120] +- **Ollama (if applicable):** [e.g., 0.1.30, 0.1.32-rc1] + - **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04] - **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3dd5eaf6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/backend" + schedule: + interval: daily + time: "13:00" + groups: + python-packages: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4c4cfa3b..81948306 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,14 +2,16 @@ - [ ] **Description:** Briefly describe the changes in this pull request. - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description. -- [ ] **Documentation:** Have you updated relevant documentation? +- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources? - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation? +- [ ] **Testing:** Have you written and run sufficient tests for the changes? +- [ ] **Code Review:** Have you self-reviewed your code and addressed any coding standard issues? --- ## Description -[Insert a brief description of the changes made in this pull request] +[Insert a brief description of the changes made in this pull request, including any relevant motivation and impact.] --- @@ -17,16 +19,32 @@ ### Added -- [List any new features or additions] +- [List any new features, functionalities, or additions] ### Fixed -- [List any fixes or corrections] +- [List any fixes, corrections, or bug fixes] ### Changed -- [List any changes or updates] +- [List any changes, updates, refactorings, or optimizations] ### Removed -- [List any removed features or files] +- [List any removed features, files, or deprecated functionalities] + +### Security + +- [List any new or updated security-related changes, including vulnerability fixes] + +### Breaking Changes + +- [List any breaking changes affecting compatibility or functionality] + +--- + +### Additional Information + +- [Insert any additional context, notes, or explanations for the changes] + +- [Reference any related issues, commits, or other relevant information] diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 259f0c5f..036bb97a 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -57,3 +57,14 @@ jobs: path: . env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger Docker build workflow + uses: actions/github-script@v7 + with: + script: | + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'docker-build.yaml', + ref: 'v${{ steps.get_version.outputs.version }}', + }) diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 52fd305c..9d4866a4 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -1,8 +1,7 @@ -# -name: Create and publish a Docker image +name: Create and publish Docker images with specific build args -# Configures this workflow to run every time a change is pushed to the branch called `release`. on: + workflow_dispatch: push: branches: - main @@ -10,15 +9,14 @@ on: tags: - v* -# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. env: REGISTRY: git.depeuter.dev IMAGE_NAME: ${{ github.repository }} RUNNER_TOOL_CACHE: /toolcache + FULL_IMAGE_NAME: ${{ env.REGISTRY }}/${{ github.repository }} -# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. jobs: - build-and-push-image: + build-main-image: runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -26,17 +24,28 @@ jobs: permissions: contents: read packages: write - # + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + - name: Checkout repository uses: actions/checkout@v4 - # Required for multi architecture build + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - # Required for multi architecture build + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry uses: docker/login-action@v3 with: @@ -44,12 +53,11 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.CI_TOKEN }} - - name: Extract metadata for Docker images + - name: Extract metadata for Docker images (default latest tag) id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # This configuration dynamically generates tags based on the branch, tag, commit, and custom suffix for lite version. + images: ${{ env.FULL_IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=tag @@ -59,11 +67,322 @@ jobs: flavor: | latest=${{ github.ref == 'refs/heads/main' }} - - name: Build and push Docker image + - name: Build Docker image (latest) uses: docker/build-push-action@v5 + id: build with: context: . push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-main-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + build-cuda-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + + steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_TOKEN }} + + - name: Extract metadata for Docker images (default latest tag) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.FULL_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,prefix=git- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda + flavor: | + latest=${{ github.ref == 'refs/heads/main' }} + suffix=-cuda,onlatest=true + + - name: Build Docker image (cuda) + uses: docker/build-push-action@v5 + id: build + with: + context: . + push: true + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: USE_CUDA=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-cuda-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + build-ollama-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + + steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_TOKEN }} + + - name: Extract metadata for Docker images (ollama tag) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.FULL_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,prefix=git- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama + flavor: | + latest=${{ github.ref == 'refs/heads/main' }} + suffix=-ollama,onlatest=true + + - name: Build Docker image (ollama) + uses: docker/build-push-action@v5 + id: build + with: + context: . + push: true + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: USE_OLLAMA=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-ollama-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-main-images: + runs-on: ubuntu-latest + needs: [ build-main-image ] + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digests-main-* + path: /tmp/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_TOKEN }} + + - name: Extract metadata for Docker images (default latest tag) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.FULL_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,prefix=git- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + flavor: | + latest=${{ github.ref == 'refs/heads/main' }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }} + + + merge-cuda-images: + runs-on: ubuntu-latest + needs: [ build-cuda-image ] + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digests-cuda-* + path: /tmp/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_TOKEN }} + + - name: Extract metadata for Docker images (default latest tag) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.FULL_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,prefix=git- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda + flavor: | + latest=${{ github.ref == 'refs/heads/main' }} + suffix=-cuda,onlatest=true + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }} + + merge-ollama-images: + runs-on: ubuntu-latest + needs: [ build-ollama-image ] + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digests-ollama-* + path: /tmp/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_TOKEN }} + + - name: Extract metadata for Docker images (default ollama tag) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.FULL_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,prefix=git- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama + flavor: | + latest=${{ github.ref == 'refs/heads/main' }} + suffix=-ollama,onlatest=true + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index 121266bf..eec1305e 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.github/workflows/format-build-frontend.yaml @@ -29,6 +29,9 @@ jobs: - name: Format Frontend run: npm run format + - name: Run i18next + run: npm run i18n:parse + - name: Check for Changes After Format run: git diff --exit-code diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..32c33165 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,186 @@ +name: Integration Test + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + cypress-run: + name: Run Cypress Integration Tests + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Build and run Compose Stack + run: | + docker compose up --detach --build + + - name: Preload Ollama model + run: | + docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K + + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + browser: chrome + wait-on: 'http://localhost:3000' + config: baseUrl=http://localhost:3000 + + - uses: actions/upload-artifact@v4 + if: always() + name: Upload Cypress videos + with: + name: cypress-videos + path: cypress/videos + if-no-files-found: ignore + + - name: Extract Compose logs + if: always() + run: | + docker compose logs > compose-logs.txt + + - uses: actions/upload-artifact@v4 + if: always() + name: Upload Compose logs + with: + name: compose-logs + path: compose-logs.txt + if-no-files-found: ignore + + migration_test: + name: Run Migration Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 +# mysql: +# image: mysql +# env: +# MYSQL_ROOT_PASSWORD: mysql +# MYSQL_DATABASE: mysql +# options: >- +# --health-cmd "mysqladmin ping -h localhost" +# --health-interval 10s +# --health-timeout 5s +# --health-retries 5 +# ports: +# - 3306:3306 + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: yezz123/setup-uv@v4 + with: + uv-venv: venv + + - name: Activate virtualenv + run: | + . venv/bin/activate + echo PATH=$PATH >> $GITHUB_ENV + + - name: Install dependencies + run: | + uv pip install -r backend/requirements.txt + + - name: Test backend with SQLite + id: sqlite + env: + WEBUI_SECRET_KEY: secret-key + GLOBAL_LOG_LEVEL: debug + run: | + cd backend + uvicorn main:app --port "8080" --forwarded-allow-ips '*' & + UVICORN_PID=$! + # Wait up to 20 seconds for the server to start + for i in {1..20}; do + curl -s http://localhost:8080/api/config > /dev/null && break + sleep 1 + if [ $i -eq 20 ]; then + echo "Server failed to start" + kill -9 $UVICORN_PID + exit 1 + fi + done + # Check that the server is still running after 5 seconds + sleep 5 + if ! kill -0 $UVICORN_PID; then + echo "Server has stopped" + exit 1 + fi + + + - name: Test backend with Postgres + if: success() || steps.sqlite.conclusion == 'failure' + env: + WEBUI_SECRET_KEY: secret-key + GLOBAL_LOG_LEVEL: debug + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + run: | + cd backend + uvicorn main:app --port "8081" --forwarded-allow-ips '*' & + UVICORN_PID=$! + # Wait up to 20 seconds for the server to start + for i in {1..20}; do + curl -s http://localhost:8081/api/config > /dev/null && break + sleep 1 + if [ $i -eq 20 ]; then + echo "Server failed to start" + kill -9 $UVICORN_PID + exit 1 + fi + done + # Check that the server is still running after 5 seconds + sleep 5 + if ! kill -0 $UVICORN_PID; then + echo "Server has stopped" + exit 1 + fi + +# - name: Test backend with MySQL +# if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure' +# env: +# WEBUI_SECRET_KEY: secret-key +# GLOBAL_LOG_LEVEL: debug +# DATABASE_URL: mysql://root:mysql@localhost:3306/mysql +# run: | +# cd backend +# uvicorn main:app --port "8083" --forwarded-allow-ips '*' & +# UVICORN_PID=$! +# # Wait up to 20 seconds for the server to start +# for i in {1..20}; do +# curl -s http://localhost:8083/api/config > /dev/null && break +# sleep 1 +# if [ $i -eq 20 ]; then +# echo "Server failed to start" +# kill -9 $UVICORN_PID +# exit 1 +# fi +# done +# # Check that the server is still running after 5 seconds +# sleep 5 +# if ! kill -0 $UVICORN_PID; then +# echo "Server has stopped" +# exit 1 +# fi diff --git a/.gitignore b/.gitignore index 2ccac4d5..55209604 100644 --- a/.gitignore +++ b/.gitignore @@ -297,4 +297,8 @@ dist .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz -.pnp.* \ No newline at end of file +.pnp.* + +# cypress artifacts +cypress/videos +cypress/screenshots diff --git a/CHANGELOG.md b/CHANGELOG.md index b1fd38b7..0567919f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,125 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.123] - 2024-05-02 + +### Added + +- **🎨 New Landing Page Design**: Refreshed design for a more modern look and optimized use of screen space. +- **📹 Youtube RAG Pipeline**: Introduces dedicated RAG pipeline for Youtube videos, enabling interaction with video transcriptions directly. +- **🔧 Enhanced Admin Panel**: Streamlined user management with options to add users directly or in bulk via CSV import. +- **👥 '@' Model Integration**: Easily switch to specific models during conversations; old collaborative chat feature phased out. +- **🌐 Language Enhancements**: Swedish translation added, plus improvements to German, Spanish, and the addition of Doge translation. + +### Fixed + +- **🗑️ Delete Chat Shortcut**: Addressed issue where shortcut wasn't functioning. +- **🖼️ Modal Closing Bug**: Resolved unexpected closure of modal when dragging from within. +- **✏️ Edit Button Styling**: Fixed styling inconsistency with edit buttons. +- **🌐 Image Generation Compatibility Issue**: Rectified image generation compatibility issue with third-party APIs. +- **📱 iOS PWA Icon Fix**: Corrected iOS PWA home screen icon shape. +- **🔍 Scroll Gesture Bug**: Adjusted gesture sensitivity to prevent accidental activation when scrolling through code on mobile; now requires scrolling from the leftmost side to open the sidebar. + +### Changed + +- **🔄 Unlimited Context Length**: Advanced settings now allow unlimited max context length (previously limited to 16000). +- **👑 Super Admin Assignment**: The first signup is automatically assigned a super admin role, unchangeable by other admins. +- **🛡️ Admin User Restrictions**: User action buttons from the admin panel are now disabled for users with admin roles. +- **🔝 Default Model Selector**: Set as default model option now exclusively available on the landing page. + +## [0.1.122] - 2024-04-27 + +### Added + +- **🌟 Enhanced RAG Pipeline**: Now with hybrid searching via 'BM25', reranking powered by 'CrossEncoder', and configurable relevance score thresholds. +- **🛢️ External Database Support**: Seamlessly connect to custom SQLite or Postgres databases using the 'DATABASE_URL' environment variable. +- **🌐 Remote ChromaDB Support**: Introducing the capability to connect to remote ChromaDB servers. +- **👨‍💼 Improved Admin Panel**: Admins can now conveniently check users' chat lists and last active status directly from the admin panel. +- **🎨 Splash Screen**: Introducing a loading splash screen for a smoother user experience. +- **🌍 Language Support Expansion**: Added support for Bangla (bn-BD), along with enhancements to Chinese, Spanish, and Ukrainian translations. +- **💻 Improved LaTeX Rendering Performance**: Enjoy faster rendering times for LaTeX equations. +- **🔧 More Environment Variables**: Explore additional environment variables in our documentation (https://docs.openwebui.com), including the 'ENABLE_LITELLM' option to manage memory usage. + +### Fixed + +- **🔧 Ollama Compatibility**: Resolved errors occurring when Ollama server version isn't an integer, such as SHA builds or RCs. +- **🐛 Various OpenAI API Issues**: Addressed several issues related to the OpenAI API. +- **🛑 Stop Sequence Issue**: Fixed the problem where the stop sequence with a backslash '\' was not functioning. +- **🔤 Font Fallback**: Corrected font fallback issue. + +### Changed + +- **⌨️ Prompt Input Behavior on Mobile**: Enter key prompt submission disabled on mobile devices for improved user experience. + +## [0.1.121] - 2024-04-24 + +### Fixed + +- **🔧 Translation Issues**: Addressed various translation discrepancies. +- **🔒 LiteLLM Security Fix**: Updated LiteLLM version to resolve a security vulnerability. +- **🖥️ HTML Tag Display**: Rectified the issue where the '< br >' tag wasn't displaying correctly. +- **🔗 WebSocket Connection**: Resolved the failure of WebSocket connection under HTTPS security for ComfyUI server. +- **📜 FileReader Optimization**: Implemented FileReader initialization per image in multi-file drag & drop to ensure reusability. +- **🏷️ Tag Display**: Corrected tag display inconsistencies. +- **📦 Archived Chat Styling**: Fixed styling issues in archived chat. +- **🔖 Safari Copy Button Bug**: Addressed the bug where the copy button failed to copy links in Safari. + +## [0.1.120] - 2024-04-20 + +### Added + +- **📦 Archive Chat Feature**: Easily archive chats with a new sidebar button, and access archived chats via the profile button > archived chats. +- **🔊 Configurable Text-to-Speech Endpoint**: Customize your Text-to-Speech experience with configurable OpenAI endpoints. +- **🛠️ Improved Error Handling**: Enhanced error message handling for connection failures. +- **⌨️ Enhanced Shortcut**: When editing messages, use ctrl/cmd+enter to save and submit, and esc to close. +- **🌐 Language Support**: Added support for Georgian and enhanced translations for Portuguese and Vietnamese. + +### Fixed + +- **🔧 Model Selector**: Resolved issue where default model selection was not saving. +- **🔗 Share Link Copy Button**: Fixed bug where the copy button wasn't copying links in Safari. +- **🎨 Light Theme Styling**: Addressed styling issue with the light theme. + +## [0.1.119] - 2024-04-16 + +### Added + +- **🌟 Enhanced RAG Embedding Support**: Ollama, and OpenAI models can now be used for RAG embedding model. +- **🔄 Seamless Integration**: Copy 'ollama run ' directly from Ollama page to easily select and pull models. +- **🏷️ Tagging Feature**: Add tags to chats directly via the sidebar chat menu. +- **📱 Mobile Accessibility**: Swipe left and right on mobile to effortlessly open and close the sidebar. +- **🔍 Improved Navigation**: Admin panel now supports pagination for user list. +- **🌍 Additional Language Support**: Added Polish language support. + +### Fixed + +- **🌍 Language Enhancements**: Vietnamese and Spanish translations have been improved. +- **🔧 Helm Fixes**: Resolved issues with Helm trailing slash and manifest.json. + +### Changed + +- **🐳 Docker Optimization**: Updated docker image build process to utilize 'uv' for significantly faster builds compared to 'pip3'. + +## [0.1.118] - 2024-04-10 + +### Added + +- **🦙 Ollama and CUDA Images**: Added support for ':ollama' and ':cuda' tagged images. +- **👍 Enhanced Response Rating**: Now you can annotate your ratings for better feedback. +- **👤 User Initials Profile Photo**: User initials are now the default profile photo. +- **🔍 Update RAG Embedding Model**: Customize RAG embedding model directly in document settings. +- **🌍 Additional Language Support**: Added Turkish language support. + +### Fixed + +- **🔒 Share Chat Permission**: Resolved issue with chat sharing permissions. +- **🛠 Modal Close**: Modals can now be closed using the Esc key. + +### Changed + +- **🎨 Admin Panel Styling**: Refreshed styling for the admin panel. +- **🐳 Docker Image Build**: Updated docker image build process for improved efficiency. + ## [0.1.117] - 2024-04-03 ### Added diff --git a/Dockerfile b/Dockerfile index f76f8c32..faee1ac3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,82 +1,128 @@ # syntax=docker/dockerfile:1 +# Initialize device type args +# use build args in the docker build commmand with --build-arg="BUILDARG=true" +ARG USE_CUDA=false +ARG USE_OLLAMA=false +# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default) +ARG USE_CUDA_VER=cu121 +# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers +# Leaderboard: https://huggingface.co/spaces/mteb/leaderboard +# for better performance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB) +# IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them. +ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 +ARG USE_RERANKING_MODEL="" -FROM node:alpine as build +######## WebUI frontend ######## +FROM --platform=$BUILDPLATFORM node:21-alpine3.19 as build WORKDIR /app -# wget embedding model weight from alpine (does not exist from slim-buster) -RUN wget "https://chroma-onnx-models.s3.amazonaws.com/all-MiniLM-L6-v2/onnx.tar.gz" -O - | \ - tar -xzf - -C /app - COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build - +######## WebUI backend ######## FROM python:3.11-slim-bookworm as base -ENV ENV=prod -ENV PORT "" +# Use args +ARG USE_CUDA +ARG USE_OLLAMA +ARG USE_CUDA_VER +ARG USE_EMBEDDING_MODEL +ARG USE_RERANKING_MODEL -ENV OLLAMA_BASE_URL "/ollama" +## Basis ## +ENV ENV=prod \ + PORT=8080 \ + # pass build args to the build + USE_OLLAMA_DOCKER=${USE_OLLAMA} \ + USE_CUDA_DOCKER=${USE_CUDA} \ + USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \ + USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \ + USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL} -ENV OPENAI_API_BASE_URL "" -ENV OPENAI_API_KEY "" +## Basis URL Config ## +ENV OLLAMA_BASE_URL="/ollama" \ + OPENAI_API_BASE_URL="" -ENV WEBUI_SECRET_KEY "" -ENV WEBUI_AUTH_TRUSTED_EMAIL_HEADER "" - -ENV SCARF_NO_ANALYTICS true -ENV DO_NOT_TRACK true +## API Key and Security Config ## +ENV OPENAI_API_KEY="" \ + WEBUI_SECRET_KEY="" \ + SCARF_NO_ANALYTICS=true \ + DO_NOT_TRACK=true \ + ANONYMIZED_TELEMETRY=false # Use locally bundled version of the LiteLLM cost map json # to avoid repetitive startup connections ENV LITELLM_LOCAL_MODEL_COST_MAP="True" -######## Preloaded models ######## -# whisper TTS Settings -ENV WHISPER_MODEL="base" -ENV WHISPER_MODEL_DIR="/app/backend/data/cache/whisper/models" -# RAG Embedding Model Settings -# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers -# Leaderboard: https://huggingface.co/spaces/mteb/leaderboard -# for better persormance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB) -# IMPORTANT: If you change the default model (all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them. -ENV RAG_EMBEDDING_MODEL="all-MiniLM-L6-v2" -# device type for whisper tts and embbeding models - "cpu" (default), "cuda" (nvidia gpu and CUDA required) or "mps" (apple silicon) - choosing this right can lead to better performance -ENV RAG_EMBEDDING_MODEL_DEVICE_TYPE="cpu" -ENV RAG_EMBEDDING_MODEL_DIR="/app/backend/data/cache/embedding/models" -ENV SENTENCE_TRANSFORMERS_HOME $RAG_EMBEDDING_MODEL_DIR +#### Other models ######################################################### +## whisper TTS model settings ## +ENV WHISPER_MODEL="base" \ + WHISPER_MODEL_DIR="/app/backend/data/cache/whisper/models" -######## Preloaded models ######## +## RAG Embedding model settings ## +ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \ + RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \ + SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models" + +## Hugging Face download cache ## +ENV HF_HOME="/app/backend/data/cache/embedding/models" +#### Other models ########################################################## WORKDIR /app/backend +ENV HOME /root +RUN mkdir -p $HOME/.cache/chroma +RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry_user_id + +RUN if [ "$USE_OLLAMA" = "true" ]; then \ + apt-get update && \ + # Install pandoc and netcat + apt-get install -y --no-install-recommends pandoc netcat-openbsd && \ + # for RAG OCR + apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ + # install helper tools + apt-get install -y --no-install-recommends curl && \ + # install ollama + curl -fsSL https://ollama.com/install.sh | sh && \ + # cleanup + rm -rf /var/lib/apt/lists/*; \ + else \ + apt-get update && \ + # Install pandoc and netcat + apt-get install -y --no-install-recommends pandoc netcat-openbsd && \ + # for RAG OCR + apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \ + # cleanup + rm -rf /var/lib/apt/lists/*; \ + fi + # install python dependencies COPY ./backend/requirements.txt ./requirements.txt -RUN apt-get update && apt-get install ffmpeg libsm6 libxext6 -y +RUN pip3 install uv && \ + if [ "$USE_CUDA" = "true" ]; then \ + # If you use CUDA the whisper and embedding model will be downloaded on first use + pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ + uv pip install --system -r requirements.txt --no-cache-dir && \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ + python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ + else \ + pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ + uv pip install --system -r requirements.txt --no-cache-dir && \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ + python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ + fi -RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir -RUN pip3 install -r requirements.txt --no-cache-dir -# Install pandoc and netcat -# RUN python -c "import pypandoc; pypandoc.download_pandoc()" -RUN apt-get update \ - && apt-get install -y pandoc netcat-openbsd \ - && rm -rf /var/lib/apt/lists/* - -# preload embedding model -RUN python -c "import os; from chromadb.utils import embedding_functions; sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=os.environ['RAG_EMBEDDING_MODEL'], device=os.environ['RAG_EMBEDDING_MODEL_DEVICE_TYPE'])" -# preload tts model -RUN python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='auto', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])" # copy embedding weight from build -RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2 -COPY --from=build /app/onnx /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2/onnx +# RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2 +# COPY --from=build /app/onnx /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2/onnx # copy built frontend files COPY --from=build /app/build /app/build @@ -86,4 +132,6 @@ COPY --from=build /app/package.json /app/package.json # copy backend files COPY ./backend . +EXPOSE 8080 + CMD [ "bash", "start.sh"] diff --git a/Makefile b/Makefile index 1ec170a2..4b60b049 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,33 @@ + +ifneq ($(shell which docker-compose 2>/dev/null),) + DOCKER_COMPOSE := docker-compose +else + DOCKER_COMPOSE := docker compose +endif + install: - @docker-compose up -d + $(DOCKER_COMPOSE) up -d remove: @chmod +x confirm_remove.sh @./confirm_remove.sh - start: - @docker-compose start + $(DOCKER_COMPOSE) start startAndBuild: - docker-compose up -d --build + $(DOCKER_COMPOSE) up -d --build stop: - @docker-compose stop + $(DOCKER_COMPOSE) stop update: # Calls the LLM update script chmod +x update_ollama_models.sh @./update_ollama_models.sh @git pull - @docker-compose down + $(DOCKER_COMPOSE) down # Make sure the ollama-webui container is stopped before rebuilding @docker stop open-webui || true - @docker-compose up --build -d - @docker-compose start + $(DOCKER_COMPOSE) up --build -d + $(DOCKER_COMPOSE) start diff --git a/README.md b/README.md index e2ee284e..d4d31ba6 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,28 @@ Open WebUI is an extensible, feature-rich, and user-friendly self-hosted WebUI d - 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience. +- 🌈 **Theme Customization**: Choose from a variety of themes to personalize your Open WebUI experience. + - 💻 **Code Syntax Highlighting**: Enjoy enhanced code readability with our syntax highlighting feature. - ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction. - 📚 **Local RAG Integration**: Dive into the future of chat interactions with the groundbreaking Retrieval Augmented Generation (RAG) support. This feature seamlessly integrates document interactions into your chat experience. You can load documents directly into the chat or add files to your document library, effortlessly accessing them using `#` command in the prompt. In its alpha phase, occasional issues may arise as we actively refine and enhance this feature to ensure optimal performance and reliability. +- 🔍 **RAG Embedding Support**: Change the RAG embedding model directly in document settings, enhancing document processing. This feature supports Ollama and OpenAI models. + - 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by the URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions. - 📜 **Prompt Preset Support**: Instantly access preset prompts using the `/` command in the chat input. Load predefined conversation starters effortlessly and expedite your interactions. Effortlessly import prompts through [Open WebUI Community](https://openwebui.com/) integration. -- 👍👎 **RLHF Annotation**: Empower your messages by rating them with thumbs up and thumbs down, facilitating the creation of datasets for Reinforcement Learning from Human Feedback (RLHF). Utilize your messages to train or fine-tune models, all while ensuring the confidentiality of locally saved data. +- 👍👎 **RLHF Annotation**: Empower your messages by rating them with thumbs up and thumbs down, followed by the option to provide textual feedback, facilitating the creation of datasets for Reinforcement Learning from Human Feedback (RLHF). Utilize your messages to train or fine-tune models, all while ensuring the confidentiality of locally saved data. - 🏷️ **Conversation Tagging**: Effortlessly categorize and locate specific chats for quick reference and streamlined data collection. - 📥🗑️ **Download/Delete Models**: Easily download or remove models directly from the web UI. +- 🔄 **Update All Ollama Models**: Easily update locally installed models all at once with a convenient button, streamlining model management. + - ⬆️ **GGUF File Model Creation**: Effortlessly create Ollama models by uploading GGUF files directly from the web UI. Streamlined process with options to upload from your machine or download GGUF files from Hugging Face. - 🤖 **Multiple Model Support**: Seamlessly switch between different chat models for diverse interactions. @@ -53,28 +59,42 @@ Open WebUI is an extensible, feature-rich, and user-friendly self-hosted WebUI d - 💬 **Collaborative Chat**: Harness the collective intelligence of multiple models by seamlessly orchestrating group conversations. Use the `@` command to specify the model, enabling dynamic and diverse dialogues within your chat interface. Immerse yourself in the collective intelligence woven into your chat environment. +- 🗨️ **Local Chat Sharing**: Generate and share chat links seamlessly between users, enhancing collaboration and communication. + - 🔄 **Regeneration History Access**: Easily revisit and explore your entire regeneration history. - 📜 **Chat History**: Effortlessly access and manage your conversation history. +- 📬 **Archive Chats**: Effortlessly store away completed conversations with LLMs for future reference, maintaining a tidy and clutter-free chat interface while allowing for easy retrieval and reference. + - 📤📥 **Import/Export Chat History**: Seamlessly move your chat data in and out of the platform. - 🗣️ **Voice Input Support**: Engage with your model through voice interactions; enjoy the convenience of talking to your model directly. Additionally, explore the option for sending voice input automatically after 3 seconds of silence for a streamlined experience. +- 🔊 **Configurable Text-to-Speech Endpoint**: Customize your Text-to-Speech experience with configurable OpenAI endpoints. + - ⚙️ **Fine-Tuned Control with Advanced Parameters**: Gain a deeper level of control by adjusting parameters such as temperature and defining your system prompts to tailor the conversation to your specific preferences and needs. -- 🎨🤖 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using AUTOMATIC1111 API (local) and DALL-E, enriching your chat experience with dynamic visual content. +- 🎨🤖 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using options such as AUTOMATIC1111 API (local), ComfyUI (local), and DALL-E, enriching your chat experience with dynamic visual content. - 🤝 **OpenAI API Integration**: Effortlessly integrate OpenAI-compatible API for versatile conversations alongside Ollama models. Customize the API Base URL to link with **LMStudio, Mistral, OpenRouter, and more**. - ✨ **Multiple OpenAI-Compatible API Support**: Seamlessly integrate and customize various OpenAI-compatible APIs, enhancing the versatility of your chat interactions. +- 🔑 **API Key Generation Support**: Generate secret keys to leverage Open WebUI with OpenAI libraries, simplifying integration and development. + - 🔗 **External Ollama Server Connection**: Seamlessly link to an external Ollama server hosted on a different address by configuring the environment variable. - 🔀 **Multiple Ollama Instance Load Balancing**: Effortlessly distribute chat requests across multiple Ollama instances for enhanced performance and reliability. - 👥 **Multi-User Management**: Easily oversee and administer users via our intuitive admin panel, streamlining user management processes. +- 🔗 **Webhook Integration**: Subscribe to new user sign-up events via webhook (compatible with Google Chat and Microsoft Teams), providing real-time notifications and automation capabilities. + +- 🛡️ **Model Whitelisting**: Admins can whitelist models for users with the 'user' role, enhancing security and access control. + +- 📧 **Trusted Email Authentication**: Authenticate using a trusted email header, adding an additional layer of security and authentication. + - 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators. - 🔒 **Backend Reverse Proxy Support**: Bolster security through direct communication between Open WebUI backend and Ollama. This key feature eliminates the need to expose Ollama over LAN. Requests made to the '/ollama/api' route from the web UI are seamlessly redirected to Ollama from the backend, enhancing overall system security. @@ -94,24 +114,27 @@ Don't forget to explore our sibling project, [Open WebUI Community](https://open ### Quick Start with Docker 🐳 -> [!IMPORTANT] +> [!WARNING] > When using Docker to install Open WebUI, make sure to include the `-v open-webui:/app/backend/data` in your Docker command. This step is crucial as it ensures your database is properly mounted and prevents any loss of data. -- **If Ollama is on your computer**, use this command: +> [!TIP] +> If you wish to utilize Open WebUI with Ollama included or CUDA acceleration, we recommend utilizing our official images tagged with either `:cuda` or `:ollama`. To enable CUDA, you must install the [Nvidia CUDA container toolkit](https://docs.nvidia.com/dgx/nvidia-container-runtime-upgrade/) on your Linux/WSL system. - ```bash - docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main - ``` +**If Ollama is on your computer**, use this command: -- **If Ollama is on a Different Server**, use this command: +```bash +docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main +``` -- To connect to Ollama on another server, change the `OLLAMA_BASE_URL` to the server's URL: +**If Ollama is on a Different Server**, use this command: - ```bash - docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=https://example.com -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main - ``` +To connect to Ollama on another server, change the `OLLAMA_BASE_URL` to the server's URL: -- After installation, you can access Open WebUI at [http://localhost:3000](http://localhost:3000). Enjoy! 😄 +```bash +docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=https://example.com -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main +``` + +After installation, you can access Open WebUI at [http://localhost:3000](http://localhost:3000). Enjoy! 😄 #### Open WebUI: Server Connection Error @@ -182,4 +205,4 @@ If you have any questions, suggestions, or need assistance, please open an issue --- -Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open Web UI even more amazing together! 💪 +Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open WebUI even more amazing together! 💪 diff --git a/backend/apps/audio/main.py b/backend/apps/audio/main.py index bb3cd053..addaf4b7 100644 --- a/backend/apps/audio/main.py +++ b/backend/apps/audio/main.py @@ -10,8 +10,19 @@ from fastapi import ( File, Form, ) + +from fastapi.responses import StreamingResponse, JSONResponse, FileResponse + from fastapi.middleware.cors import CORSMiddleware from faster_whisper import WhisperModel +from pydantic import BaseModel + + +import requests +import hashlib +from pathlib import Path +import json + from constants import ERROR_MESSAGES from utils.utils import ( @@ -28,6 +39,10 @@ from config import ( UPLOAD_DIR, WHISPER_MODEL, WHISPER_MODEL_DIR, + WHISPER_MODEL_AUTO_UPDATE, + DEVICE_TYPE, + AUDIO_OPENAI_API_BASE_URL, + AUDIO_OPENAI_API_KEY, ) log = logging.getLogger(__name__) @@ -43,7 +58,103 @@ app.add_middleware( ) -@app.post("/transcribe") +app.state.OPENAI_API_BASE_URL = AUDIO_OPENAI_API_BASE_URL +app.state.OPENAI_API_KEY = AUDIO_OPENAI_API_KEY + +# setting device type for whisper model +whisper_device_type = DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu" +log.info(f"whisper_device_type: {whisper_device_type}") + +SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") +SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + +class OpenAIConfigUpdateForm(BaseModel): + url: str + key: str + + +@app.get("/config") +async def get_openai_config(user=Depends(get_admin_user)): + return { + "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.OPENAI_API_KEY, + } + + +@app.post("/config/update") +async def update_openai_config( + form_data: OpenAIConfigUpdateForm, user=Depends(get_admin_user) +): + if form_data.key == "": + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) + + app.state.OPENAI_API_BASE_URL = form_data.url + app.state.OPENAI_API_KEY = form_data.key + + return { + "status": True, + "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.OPENAI_API_KEY, + } + + +@app.post("/speech") +async def speech(request: Request, user=Depends(get_verified_user)): + body = await request.body() + name = hashlib.sha256(body).hexdigest() + + file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3") + file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json") + + # Check if the file already exists in the cache + if file_path.is_file(): + return FileResponse(file_path) + + headers = {} + headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}" + headers["Content-Type"] = "application/json" + + r = None + try: + r = requests.post( + url=f"{app.state.OPENAI_API_BASE_URL}/audio/speech", + data=body, + headers=headers, + stream=True, + ) + + r.raise_for_status() + + # Save the streaming content to a file + with open(file_path, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + + with open(file_body_path, "w") as f: + json.dump(json.loads(body.decode("utf-8")), f) + + # Return the saved file + return FileResponse(file_path) + + except Exception as e: + log.exception(e) + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = r.json() + if "error" in res: + error_detail = f"External: {res['error']['message']}" + except: + error_detail = f"External: {e}" + + raise HTTPException( + status_code=r.status_code if r != None else 500, + detail=error_detail, + ) + + +@app.post("/transcriptions") def transcribe( file: UploadFile = File(...), user=Depends(get_current_user), @@ -64,12 +175,24 @@ def transcribe( f.write(contents) f.close() - model = WhisperModel( - WHISPER_MODEL, - device="auto", - compute_type="int8", - download_root=WHISPER_MODEL_DIR, - ) + whisper_kwargs = { + "model_size_or_path": WHISPER_MODEL, + "device": whisper_device_type, + "compute_type": "int8", + "download_root": WHISPER_MODEL_DIR, + "local_files_only": not WHISPER_MODEL_AUTO_UPDATE, + } + + log.debug(f"whisper_kwargs: {whisper_kwargs}") + + try: + model = WhisperModel(**whisper_kwargs) + except: + log.warning( + "WhisperModel initialization failed, attempting download with local_files_only=False" + ) + whisper_kwargs["local_files_only"] = False + model = WhisperModel(**whisper_kwargs) segments, info = model.transcribe(file_path, beam_size=5) log.info( diff --git a/backend/apps/images/main.py b/backend/apps/images/main.py index af8cbf7c..f45cf0d1 100644 --- a/backend/apps/images/main.py +++ b/backend/apps/images/main.py @@ -24,12 +24,25 @@ from utils.misc import calculate_sha256 from typing import Optional from pydantic import BaseModel from pathlib import Path +import mimetypes import uuid import base64 import json import logging -from config import SRC_LOG_LEVELS, CACHE_DIR, AUTOMATIC1111_BASE_URL, COMFYUI_BASE_URL +from config import ( + SRC_LOG_LEVELS, + CACHE_DIR, + IMAGE_GENERATION_ENGINE, + ENABLE_IMAGE_GENERATION, + AUTOMATIC1111_BASE_URL, + COMFYUI_BASE_URL, + IMAGES_OPENAI_API_BASE_URL, + IMAGES_OPENAI_API_KEY, + IMAGE_GENERATION_MODEL, + IMAGE_SIZE, + IMAGE_STEPS, +) log = logging.getLogger(__name__) @@ -47,19 +60,21 @@ app.add_middleware( allow_headers=["*"], ) -app.state.ENGINE = "" -app.state.ENABLED = False +app.state.ENGINE = IMAGE_GENERATION_ENGINE +app.state.ENABLED = ENABLE_IMAGE_GENERATION -app.state.OPENAI_API_KEY = "" -app.state.MODEL = "" +app.state.OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL +app.state.OPENAI_API_KEY = IMAGES_OPENAI_API_KEY + +app.state.MODEL = IMAGE_GENERATION_MODEL app.state.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL app.state.COMFYUI_BASE_URL = COMFYUI_BASE_URL -app.state.IMAGE_SIZE = "512x512" -app.state.IMAGE_STEPS = 50 +app.state.IMAGE_SIZE = IMAGE_SIZE +app.state.IMAGE_STEPS = IMAGE_STEPS @app.get("/config") @@ -125,27 +140,33 @@ async def update_engine_url( } -class OpenAIKeyUpdateForm(BaseModel): +class OpenAIConfigUpdateForm(BaseModel): + url: str key: str -@app.get("/key") -async def get_openai_key(user=Depends(get_admin_user)): - return {"OPENAI_API_KEY": app.state.OPENAI_API_KEY} +@app.get("/openai/config") +async def get_openai_config(user=Depends(get_admin_user)): + return { + "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.OPENAI_API_KEY, + } -@app.post("/key/update") -async def update_openai_key( - form_data: OpenAIKeyUpdateForm, user=Depends(get_admin_user) +@app.post("/openai/config/update") +async def update_openai_config( + form_data: OpenAIConfigUpdateForm, user=Depends(get_admin_user) ): - if form_data.key == "": raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) + app.state.OPENAI_API_BASE_URL = form_data.url app.state.OPENAI_API_KEY = form_data.key + return { - "OPENAI_API_KEY": app.state.OPENAI_API_KEY, "status": True, + "OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.OPENAI_API_KEY, } @@ -295,35 +316,61 @@ class GenerateImageForm(BaseModel): def save_b64_image(b64_str): - image_id = str(uuid.uuid4()) - file_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.png") - try: - # Split the base64 string to get the actual image data - img_data = base64.b64decode(b64_str) + image_id = str(uuid.uuid4()) - # Write the image data to a file - with open(file_path, "wb") as f: - f.write(img_data) + if "," in b64_str: + header, encoded = b64_str.split(",", 1) + mime_type = header.split(";")[0] + + img_data = base64.b64decode(encoded) + image_format = mimetypes.guess_extension(mime_type) + + image_filename = f"{image_id}{image_format}" + file_path = IMAGE_CACHE_DIR / f"{image_filename}" + with open(file_path, "wb") as f: + f.write(img_data) + return image_filename + else: + image_filename = f"{image_id}.png" + file_path = IMAGE_CACHE_DIR.joinpath(image_filename) + + img_data = base64.b64decode(b64_str) + + # Write the image data to a file + with open(file_path, "wb") as f: + f.write(img_data) + return image_filename - return image_id except Exception as e: - log.error(f"Error saving image: {e}") + log.exception(f"Error saving image: {e}") return None def save_url_image(url): image_id = str(uuid.uuid4()) - file_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.png") - try: r = requests.get(url) r.raise_for_status() + if r.headers["content-type"].split("/")[0] == "image": - with open(file_path, "wb") as image_file: - image_file.write(r.content) + mime_type = r.headers["content-type"] + image_format = mimetypes.guess_extension(mime_type) + + if not image_format: + raise ValueError("Could not determine image type from MIME type") + + image_filename = f"{image_id}{image_format}" + + file_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}") + with open(file_path, "wb") as image_file: + for chunk in r.iter_content(chunk_size=8192): + image_file.write(chunk) + return image_filename + else: + log.error(f"Url does not point to an image.") + return None - return image_id except Exception as e: log.exception(f"Error saving image: {e}") return None @@ -354,7 +401,7 @@ def generate_image( } r = requests.post( - url=f"https://api.openai.com/v1/images/generations", + url=f"{app.state.OPENAI_API_BASE_URL}/images/generations", json=data, headers=headers, ) @@ -365,9 +412,9 @@ def generate_image( images = [] for image in res["data"]: - image_id = save_b64_image(image["b64_json"]) - images.append({"url": f"/cache/image/generations/{image_id}.png"}) - file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json") + image_filename = save_b64_image(image["b64_json"]) + images.append({"url": f"/cache/image/generations/{image_filename}"}) + file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json") with open(file_body_path, "w") as f: json.dump(data, f) @@ -402,9 +449,9 @@ def generate_image( images = [] for image in res["data"]: - image_id = save_url_image(image["url"]) - images.append({"url": f"/cache/image/generations/{image_id}.png"}) - file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json") + image_filename = save_url_image(image["url"]) + images.append({"url": f"/cache/image/generations/{image_filename}"}) + file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json") with open(file_body_path, "w") as f: json.dump(data.model_dump(exclude_none=True), f) @@ -440,9 +487,9 @@ def generate_image( images = [] for image in res["images"]: - image_id = save_b64_image(image) - images.append({"url": f"/cache/image/generations/{image_id}.png"}) - file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_id}.json") + image_filename = save_b64_image(image) + images.append({"url": f"/cache/image/generations/{image_filename}"}) + file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json") with open(file_body_path, "w") as f: json.dump({**data, "info": res["info"]}, f) diff --git a/backend/apps/images/utils/comfyui.py b/backend/apps/images/utils/comfyui.py index 39333325..05df1c16 100644 --- a/backend/apps/images/utils/comfyui.py +++ b/backend/apps/images/utils/comfyui.py @@ -195,7 +195,7 @@ class ImageGenerationPayload(BaseModel): def comfyui_generate_image( model: str, payload: ImageGenerationPayload, client_id, base_url ): - host = base_url.replace("http://", "").replace("https://", "") + ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") comfyui_prompt = json.loads(COMFYUI_DEFAULT_PROMPT) @@ -217,7 +217,7 @@ def comfyui_generate_image( try: ws = websocket.WebSocket() - ws.connect(f"ws://{host}/ws?clientId={client_id}") + ws.connect(f"{ws_url}/ws?clientId={client_id}") log.info("WebSocket connection established.") except Exception as e: log.exception(f"Failed to connect to WebSocket server: {e}") diff --git a/backend/apps/litellm/main.py b/backend/apps/litellm/main.py index a9922aad..2a37622f 100644 --- a/backend/apps/litellm/main.py +++ b/backend/apps/litellm/main.py @@ -1,100 +1,372 @@ +import sys + +from fastapi import FastAPI, Depends, HTTPException +from fastapi.routing import APIRoute +from fastapi.middleware.cors import CORSMiddleware + import logging - -from litellm.proxy.proxy_server import ProxyConfig, initialize -from litellm.proxy.proxy_server import app - from fastapi import FastAPI, Request, Depends, status, Response from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.responses import StreamingResponse import json +import time +import requests -from utils.utils import get_http_authorization_cred, get_current_user +from pydantic import BaseModel, ConfigDict +from typing import Optional, List + +from utils.utils import get_verified_user, get_current_user, get_admin_user from config import SRC_LOG_LEVELS, ENV +from constants import MESSAGES + +import os log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["LITELLM"]) from config import ( - MODEL_FILTER_ENABLED, + ENABLE_LITELLM, + ENABLE_MODEL_FILTER, MODEL_FILTER_LIST, + DATA_DIR, + LITELLM_PROXY_PORT, + LITELLM_PROXY_HOST, +) + +from litellm.utils import get_llm_provider + +import asyncio +import subprocess +import yaml + +app = FastAPI() + +origins = ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) -proxy_config = ProxyConfig() +LITELLM_CONFIG_DIR = f"{DATA_DIR}/litellm/config.yaml" + +with open(LITELLM_CONFIG_DIR, "r") as file: + litellm_config = yaml.safe_load(file) -async def config(): - router, model_list, general_settings = await proxy_config.load_config( - router=None, config_file_path="./data/litellm/config.yaml" - ) +app.state.ENABLE = ENABLE_LITELLM +app.state.CONFIG = litellm_config - await initialize(config="./data/litellm/config.yaml", telemetry=False) +# Global variable to store the subprocess reference +background_process = None + +CONFLICT_ENV_VARS = [ + # Uvicorn uses PORT, so LiteLLM might use it as well + "PORT", + # LiteLLM uses DATABASE_URL for Prisma connections + "DATABASE_URL", +] -async def startup(): - await config() +async def run_background_process(command): + global background_process + log.info("run_background_process") + + try: + # Log the command to be executed + log.info(f"Executing command: {command}") + # Filter environment variables known to conflict with litellm + env = {k: v for k, v in os.environ.items() if k not in CONFLICT_ENV_VARS} + # Execute the command and create a subprocess + process = await asyncio.create_subprocess_exec( + *command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env + ) + background_process = process + log.info("Subprocess started successfully.") + + # Capture STDERR for debugging purposes + stderr_output = await process.stderr.read() + stderr_text = stderr_output.decode().strip() + if stderr_text: + log.info(f"Subprocess STDERR: {stderr_text}") + + # log.info output line by line + async for line in process.stdout: + log.info(line.decode().strip()) + + # Wait for the process to finish + returncode = await process.wait() + log.info(f"Subprocess exited with return code {returncode}") + except Exception as e: + log.error(f"Failed to start subprocess: {e}") + raise # Optionally re-raise the exception if you want it to propagate + + +async def start_litellm_background(): + log.info("start_litellm_background") + # Command to run in the background + command = [ + "litellm", + "--port", + str(LITELLM_PROXY_PORT), + "--host", + LITELLM_PROXY_HOST, + "--telemetry", + "False", + "--config", + LITELLM_CONFIG_DIR, + ] + + await run_background_process(command) + + +async def shutdown_litellm_background(): + log.info("shutdown_litellm_background") + global background_process + if background_process: + background_process.terminate() + await background_process.wait() # Ensure the process has terminated + log.info("Subprocess terminated") + background_process = None @app.on_event("startup") -async def on_startup(): - await startup() +async def startup_event(): + log.info("startup_event") + # TODO: Check config.yaml file and create one + asyncio.create_task(start_litellm_background()) -app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED +app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST -@app.middleware("http") -async def auth_middleware(request: Request, call_next): - auth_header = request.headers.get("Authorization", "") - request.state.user = None +@app.get("/") +async def get_status(): + return {"status": True} + + +async def restart_litellm(): + """ + Endpoint to restart the litellm background service. + """ + log.info("Requested restart of litellm service.") + try: + # Shut down the existing process if it is running + await shutdown_litellm_background() + log.info("litellm service shutdown complete.") + + # Restart the background service + + asyncio.create_task(start_litellm_background()) + log.info("litellm service restart complete.") + + return { + "status": "success", + "message": "litellm service restarted successfully.", + } + except Exception as e: + log.info(f"Error restarting litellm service: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) + + +@app.get("/restart") +async def restart_litellm_handler(user=Depends(get_admin_user)): + return await restart_litellm() + + +@app.get("/config") +async def get_config(user=Depends(get_admin_user)): + return app.state.CONFIG + + +class LiteLLMConfigForm(BaseModel): + general_settings: Optional[dict] = None + litellm_settings: Optional[dict] = None + model_list: Optional[List[dict]] = None + router_settings: Optional[dict] = None + + model_config = ConfigDict(protected_namespaces=()) + + +@app.post("/config/update") +async def update_config(form_data: LiteLLMConfigForm, user=Depends(get_admin_user)): + app.state.CONFIG = form_data.model_dump(exclude_none=True) + + with open(LITELLM_CONFIG_DIR, "w") as file: + yaml.dump(app.state.CONFIG, file) + + await restart_litellm() + return app.state.CONFIG + + +@app.get("/models") +@app.get("/v1/models") +async def get_models(user=Depends(get_current_user)): + + if app.state.ENABLE: + while not background_process: + await asyncio.sleep(0.1) + + url = f"http://localhost:{LITELLM_PROXY_PORT}/v1" + r = None + try: + r = requests.request(method="GET", url=f"{url}/models") + r.raise_for_status() + + data = r.json() + + if app.state.ENABLE_MODEL_FILTER: + if user and user.role == "user": + data["data"] = list( + filter( + lambda model: model["id"] in app.state.MODEL_FILTER_LIST, + data["data"], + ) + ) + + return data + except Exception as e: + + log.exception(e) + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = r.json() + if "error" in res: + error_detail = f"External: {res['error']}" + except: + error_detail = f"External: {e}" + + return { + "data": [ + { + "id": model["model_name"], + "object": "model", + "created": int(time.time()), + "owned_by": "openai", + } + for model in app.state.CONFIG["model_list"] + ], + "object": "list", + } + else: + return { + "data": [], + "object": "list", + } + + +@app.get("/model/info") +async def get_model_list(user=Depends(get_admin_user)): + return {"data": app.state.CONFIG["model_list"]} + + +class AddLiteLLMModelForm(BaseModel): + model_name: str + litellm_params: dict + + model_config = ConfigDict(protected_namespaces=()) + + +@app.post("/model/new") +async def add_model_to_config( + form_data: AddLiteLLMModelForm, user=Depends(get_admin_user) +): + try: + get_llm_provider(model=form_data.model_name) + app.state.CONFIG["model_list"].append(form_data.model_dump()) + + with open(LITELLM_CONFIG_DIR, "w") as file: + yaml.dump(app.state.CONFIG, file) + + await restart_litellm() + + return {"message": MESSAGES.MODEL_ADDED(form_data.model_name)} + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) + + +class DeleteLiteLLMModelForm(BaseModel): + id: str + + +@app.post("/model/delete") +async def delete_model_from_config( + form_data: DeleteLiteLLMModelForm, user=Depends(get_admin_user) +): + app.state.CONFIG["model_list"] = [ + model + for model in app.state.CONFIG["model_list"] + if model["model_name"] != form_data.id + ] + + with open(LITELLM_CONFIG_DIR, "w") as file: + yaml.dump(app.state.CONFIG, file) + + await restart_litellm() + + return {"message": MESSAGES.MODEL_DELETED(form_data.id)} + + +@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) +async def proxy(path: str, request: Request, user=Depends(get_verified_user)): + body = await request.body() + + url = f"http://localhost:{LITELLM_PROXY_PORT}" + + target_url = f"{url}/{path}" + + headers = {} + # headers["Authorization"] = f"Bearer {key}" + headers["Content-Type"] = "application/json" + + r = None try: - user = get_current_user(get_http_authorization_cred(auth_header)) - log.debug(f"user: {user}") - request.state.user = user + r = requests.request( + method=request.method, + url=target_url, + data=body, + headers=headers, + stream=True, + ) + + r.raise_for_status() + + # Check if response is SSE + if "text/event-stream" in r.headers.get("Content-Type", ""): + return StreamingResponse( + r.iter_content(chunk_size=8192), + status_code=r.status_code, + headers=dict(r.headers), + ) + else: + response_data = r.json() + return response_data except Exception as e: - return JSONResponse(status_code=400, content={"detail": str(e)}) + log.exception(e) + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = r.json() + if "error" in res: + error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" + except: + error_detail = f"External: {e}" - response = await call_next(request) - return response - - -class ModifyModelsResponseMiddleware(BaseHTTPMiddleware): - async def dispatch( - self, request: Request, call_next: RequestResponseEndpoint - ) -> Response: - - response = await call_next(request) - user = request.state.user - - if "/models" in request.url.path: - if isinstance(response, StreamingResponse): - # Read the content of the streaming response - body = b"" - async for chunk in response.body_iterator: - body += chunk - - data = json.loads(body.decode("utf-8")) - - if app.state.MODEL_FILTER_ENABLED: - if user and user.role == "user": - data["data"] = list( - filter( - lambda model: model["id"] - in app.state.MODEL_FILTER_LIST, - data["data"], - ) - ) - - # Modified Flag - data["modified"] = True - return JSONResponse(content=data) - - return response - - -app.add_middleware(ModifyModelsResponseMiddleware) + raise HTTPException( + status_code=r.status_code if r else 500, detail=error_detail + ) diff --git a/backend/apps/ollama/main.py b/backend/apps/ollama/main.py index b89d7bf5..f1c836fa 100644 --- a/backend/apps/ollama/main.py +++ b/backend/apps/ollama/main.py @@ -16,6 +16,7 @@ from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel, ConfigDict import os +import re import copy import random import requests @@ -36,7 +37,7 @@ from utils.utils import decode_token, get_current_user, get_admin_user from config import ( SRC_LOG_LEVELS, OLLAMA_BASE_URLS, - MODEL_FILTER_ENABLED, + ENABLE_MODEL_FILTER, MODEL_FILTER_LIST, UPLOAD_DIR, ) @@ -55,7 +56,7 @@ app.add_middleware( ) -app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED +app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST app.state.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS @@ -168,7 +169,7 @@ async def get_ollama_tags( if url_idx == None: models = await get_all_models() - if app.state.MODEL_FILTER_ENABLED: + if app.state.ENABLE_MODEL_FILTER: if user.role == "user": models["models"] = list( filter( @@ -215,7 +216,10 @@ async def get_ollama_versions(url_idx: Optional[int] = None): if len(responses) > 0: lowest_version = min( - responses, key=lambda x: tuple(map(int, x["version"].split("."))) + responses, + key=lambda x: tuple( + map(int, re.sub(r"^v|-.*", "", x["version"]).split(".")) + ), ) return {"version": lowest_version["version"]} @@ -611,8 +615,13 @@ async def generate_embeddings( user=Depends(get_current_user), ): if url_idx == None: - if form_data.model in app.state.MODELS: - url_idx = random.choice(app.state.MODELS[form_data.model]["urls"]) + model = form_data.model + + if ":" not in model: + model = f"{model}:latest" + + if model in app.state.MODELS: + url_idx = random.choice(app.state.MODELS[model]["urls"]) else: raise HTTPException( status_code=400, @@ -648,6 +657,60 @@ async def generate_embeddings( ) +def generate_ollama_embeddings( + form_data: GenerateEmbeddingsForm, + url_idx: Optional[int] = None, +): + + log.info(f"generate_ollama_embeddings {form_data}") + + if url_idx == None: + model = form_data.model + + if ":" not in model: + model = f"{model}:latest" + + if model in app.state.MODELS: + url_idx = random.choice(app.state.MODELS[model]["urls"]) + else: + raise HTTPException( + status_code=400, + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model), + ) + + url = app.state.OLLAMA_BASE_URLS[url_idx] + log.info(f"url: {url}") + + try: + r = requests.request( + method="POST", + url=f"{url}/api/embeddings", + data=form_data.model_dump_json(exclude_none=True).encode(), + ) + r.raise_for_status() + + data = r.json() + + log.info(f"generate_ollama_embeddings {data}") + + if "embedding" in data: + return data["embedding"] + else: + raise "Something went wrong :/" + except Exception as e: + log.exception(e) + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = r.json() + if "error" in res: + error_detail = f"Ollama: {res['error']}" + except: + error_detail = f"Ollama: {e}" + + raise error_detail + + class GenerateCompletionForm(BaseModel): model: str prompt: str @@ -671,8 +734,13 @@ async def generate_completion( ): if url_idx == None: - if form_data.model in app.state.MODELS: - url_idx = random.choice(app.state.MODELS[form_data.model]["urls"]) + model = form_data.model + + if ":" not in model: + model = f"{model}:latest" + + if model in app.state.MODELS: + url_idx = random.choice(app.state.MODELS[model]["urls"]) else: raise HTTPException( status_code=400, @@ -769,8 +837,13 @@ async def generate_chat_completion( ): if url_idx == None: - if form_data.model in app.state.MODELS: - url_idx = random.choice(app.state.MODELS[form_data.model]["urls"]) + model = form_data.model + + if ":" not in model: + model = f"{model}:latest" + + if model in app.state.MODELS: + url_idx = random.choice(app.state.MODELS[model]["urls"]) else: raise HTTPException( status_code=400, @@ -873,8 +946,13 @@ async def generate_openai_chat_completion( ): if url_idx == None: - if form_data.model in app.state.MODELS: - url_idx = random.choice(app.state.MODELS[form_data.model]["urls"]) + model = form_data.model + + if ":" not in model: + model = f"{model}:latest" + + if model in app.state.MODELS: + url_idx = random.choice(app.state.MODELS[model]["urls"]) else: raise HTTPException( status_code=400, diff --git a/backend/apps/openai/main.py b/backend/apps/openai/main.py index 4098d73a..b5d1e68d 100644 --- a/backend/apps/openai/main.py +++ b/backend/apps/openai/main.py @@ -24,7 +24,7 @@ from config import ( OPENAI_API_BASE_URLS, OPENAI_API_KEYS, CACHE_DIR, - MODEL_FILTER_ENABLED, + ENABLE_MODEL_FILTER, MODEL_FILTER_LIST, ) from typing import List, Optional @@ -45,7 +45,7 @@ app.add_middleware( allow_headers=["*"], ) -app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED +app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST app.state.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS @@ -80,6 +80,7 @@ async def get_openai_urls(user=Depends(get_admin_user)): @app.post("/urls/update") async def update_openai_urls(form_data: UrlsUpdateForm, user=Depends(get_admin_user)): + await get_all_models() app.state.OPENAI_API_BASE_URLS = form_data.urls return {"OPENAI_API_BASE_URLS": app.state.OPENAI_API_BASE_URLS} @@ -170,6 +171,7 @@ async def fetch_url(url, key): def merge_models_lists(model_lists): + log.info(f"merge_models_lists {model_lists}") merged_list = [] for idx, models in enumerate(model_lists): @@ -198,14 +200,16 @@ async def get_all_models(): ] responses = await asyncio.gather(*tasks) + log.info(f"get_all_models:responses() {responses}") + models = { "data": merge_models_lists( list( map( lambda response: ( response["data"] - if response and "data" in response - else None + if (response and "data" in response) + else (response if isinstance(response, list) else None) ), responses, ) @@ -224,7 +228,7 @@ async def get_all_models(): async def get_models(url_idx: Optional[int] = None, user=Depends(get_current_user)): if url_idx == None: models = await get_all_models() - if app.state.MODEL_FILTER_ENABLED: + if app.state.ENABLE_MODEL_FILTER: if user.role == "user": models["data"] = list( filter( @@ -341,7 +345,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): try: res = r.json() if "error" in res: - error_detail = f"External: {res['error']}" + error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" except: error_detail = f"External: {e}" diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index 671429bb..a3e3c113 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -13,8 +13,7 @@ import os, shutil, logging, re from pathlib import Path from typing import List -from sentence_transformers import SentenceTransformer -from chromadb.utils import embedding_functions +from chromadb.utils.batch_utils import create_batches from langchain_community.document_loaders import ( WebBaseLoader, @@ -29,15 +28,22 @@ from langchain_community.document_loaders import ( UnstructuredXMLLoader, UnstructuredRSTLoader, UnstructuredExcelLoader, + YoutubeLoader, ) from langchain.text_splitter import RecursiveCharacterTextSplitter +import validators +import urllib.parse +import socket + + from pydantic import BaseModel from typing import Optional import mimetypes import uuid import json +import sentence_transformers from apps.web.models.documents import ( Documents, @@ -45,7 +51,14 @@ from apps.web.models.documents import ( DocumentResponse, ) -from apps.rag.utils import query_doc, query_collection +from apps.rag.utils import ( + get_model_path, + get_embedding_function, + query_doc, + query_doc_with_hybrid_search, + query_collection, + query_collection_with_hybrid_search, +) from utils.misc import ( calculate_sha256, @@ -54,16 +67,30 @@ from utils.misc import ( extract_folders_after_data_docs, ) from utils.utils import get_current_user, get_admin_user + from config import ( SRC_LOG_LEVELS, UPLOAD_DIR, DOCS_DIR, + RAG_TOP_K, + RAG_RELEVANCE_THRESHOLD, + RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_MODEL, - RAG_EMBEDDING_MODEL_DEVICE_TYPE, + RAG_EMBEDDING_MODEL_AUTO_UPDATE, + RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, + ENABLE_RAG_HYBRID_SEARCH, + RAG_RERANKING_MODEL, + PDF_EXTRACT_IMAGES, + RAG_RERANKING_MODEL_AUTO_UPDATE, + RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, + RAG_OPENAI_API_BASE_URL, + RAG_OPENAI_API_KEY, + DEVICE_TYPE, CHROMA_CLIENT, CHUNK_SIZE, CHUNK_OVERLAP, RAG_TEMPLATE, + ENABLE_LOCAL_WEB_FETCH, ) from constants import ERROR_MESSAGES @@ -71,34 +98,77 @@ from constants import ERROR_MESSAGES log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -# -# if RAG_EMBEDDING_MODEL: -# sentence_transformer_ef = SentenceTransformer( -# model_name_or_path=RAG_EMBEDDING_MODEL, -# cache_folder=RAG_EMBEDDING_MODEL_DIR, -# device=RAG_EMBEDDING_MODEL_DEVICE_TYPE, -# ) - - app = FastAPI() -app.state.PDF_EXTRACT_IMAGES = False +app.state.TOP_K = RAG_TOP_K +app.state.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD + +app.state.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH + app.state.CHUNK_SIZE = CHUNK_SIZE app.state.CHUNK_OVERLAP = CHUNK_OVERLAP -app.state.RAG_TEMPLATE = RAG_TEMPLATE -app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL -app.state.TOP_K = 4 -app.state.sentence_transformer_ef = ( - embedding_functions.SentenceTransformerEmbeddingFunction( - model_name=app.state.RAG_EMBEDDING_MODEL, - device=RAG_EMBEDDING_MODEL_DEVICE_TYPE, - ) +app.state.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE +app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL +app.state.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL +app.state.RAG_TEMPLATE = RAG_TEMPLATE + +app.state.OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL +app.state.OPENAI_API_KEY = RAG_OPENAI_API_KEY + +app.state.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES + + +def update_embedding_model( + embedding_model: str, + update_model: bool = False, +): + if embedding_model and app.state.RAG_EMBEDDING_ENGINE == "": + app.state.sentence_transformer_ef = sentence_transformers.SentenceTransformer( + get_model_path(embedding_model, update_model), + device=DEVICE_TYPE, + trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, + ) + else: + app.state.sentence_transformer_ef = None + + +def update_reranking_model( + reranking_model: str, + update_model: bool = False, +): + if reranking_model: + app.state.sentence_transformer_rf = sentence_transformers.CrossEncoder( + get_model_path(reranking_model, update_model), + device=DEVICE_TYPE, + trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, + ) + else: + app.state.sentence_transformer_rf = None + + +update_embedding_model( + app.state.RAG_EMBEDDING_MODEL, + RAG_EMBEDDING_MODEL_AUTO_UPDATE, +) + +update_reranking_model( + app.state.RAG_RERANKING_MODEL, + RAG_RERANKING_MODEL_AUTO_UPDATE, ) +app.state.EMBEDDING_FUNCTION = get_embedding_function( + app.state.RAG_EMBEDDING_ENGINE, + app.state.RAG_EMBEDDING_MODEL, + app.state.sentence_transformer_ef, + app.state.OPENAI_API_KEY, + app.state.OPENAI_API_BASE_URL, +) + origins = ["*"] + app.add_middleware( CORSMiddleware, allow_origins=origins, @@ -112,7 +182,7 @@ class CollectionNameForm(BaseModel): collection_name: Optional[str] = "test" -class StoreWebForm(CollectionNameForm): +class UrlForm(CollectionNameForm): url: str @@ -123,38 +193,110 @@ async def get_status(): "chunk_size": app.state.CHUNK_SIZE, "chunk_overlap": app.state.CHUNK_OVERLAP, "template": app.state.RAG_TEMPLATE, + "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, "embedding_model": app.state.RAG_EMBEDDING_MODEL, + "reranking_model": app.state.RAG_RERANKING_MODEL, } -@app.get("/embedding/model") -async def get_embedding_model(user=Depends(get_admin_user)): +@app.get("/embedding") +async def get_embedding_config(user=Depends(get_admin_user)): return { "status": True, + "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, "embedding_model": app.state.RAG_EMBEDDING_MODEL, + "openai_config": { + "url": app.state.OPENAI_API_BASE_URL, + "key": app.state.OPENAI_API_KEY, + }, } +@app.get("/reranking") +async def get_reraanking_config(user=Depends(get_admin_user)): + return {"status": True, "reranking_model": app.state.RAG_RERANKING_MODEL} + + +class OpenAIConfigForm(BaseModel): + url: str + key: str + + class EmbeddingModelUpdateForm(BaseModel): + openai_config: Optional[OpenAIConfigForm] = None + embedding_engine: str embedding_model: str -@app.post("/embedding/model/update") -async def update_embedding_model( +@app.post("/embedding/update") +async def update_embedding_config( form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user) ): - app.state.RAG_EMBEDDING_MODEL = form_data.embedding_model - app.state.sentence_transformer_ef = ( - embedding_functions.SentenceTransformerEmbeddingFunction( - model_name=app.state.RAG_EMBEDDING_MODEL, - device=RAG_EMBEDDING_MODEL_DEVICE_TYPE, - ) + log.info( + f"Updating embedding model: {app.state.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}" ) + try: + app.state.RAG_EMBEDDING_ENGINE = form_data.embedding_engine + app.state.RAG_EMBEDDING_MODEL = form_data.embedding_model - return { - "status": True, - "embedding_model": app.state.RAG_EMBEDDING_MODEL, - } + if app.state.RAG_EMBEDDING_ENGINE in ["ollama", "openai"]: + if form_data.openai_config != None: + app.state.OPENAI_API_BASE_URL = form_data.openai_config.url + app.state.OPENAI_API_KEY = form_data.openai_config.key + + update_embedding_model(app.state.RAG_EMBEDDING_MODEL, True) + + app.state.EMBEDDING_FUNCTION = get_embedding_function( + app.state.RAG_EMBEDDING_ENGINE, + app.state.RAG_EMBEDDING_MODEL, + app.state.sentence_transformer_ef, + app.state.OPENAI_API_KEY, + app.state.OPENAI_API_BASE_URL, + ) + + return { + "status": True, + "embedding_engine": app.state.RAG_EMBEDDING_ENGINE, + "embedding_model": app.state.RAG_EMBEDDING_MODEL, + "openai_config": { + "url": app.state.OPENAI_API_BASE_URL, + "key": app.state.OPENAI_API_KEY, + }, + } + except Exception as e: + log.exception(f"Problem updating embedding model: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + +class RerankingModelUpdateForm(BaseModel): + reranking_model: str + + +@app.post("/reranking/update") +async def update_reranking_config( + form_data: RerankingModelUpdateForm, user=Depends(get_admin_user) +): + log.info( + f"Updating reranking model: {app.state.RAG_RERANKING_MODEL} to {form_data.reranking_model}" + ) + try: + app.state.RAG_RERANKING_MODEL = form_data.reranking_model + + update_reranking_model(app.state.RAG_RERANKING_MODEL, True) + + return { + "status": True, + "reranking_model": app.state.RAG_RERANKING_MODEL, + } + except Exception as e: + log.exception(f"Problem updating reranking model: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(e), + ) @app.get("/config") @@ -209,12 +351,16 @@ async def get_query_settings(user=Depends(get_admin_user)): "status": True, "template": app.state.RAG_TEMPLATE, "k": app.state.TOP_K, + "r": app.state.RELEVANCE_THRESHOLD, + "hybrid": app.state.ENABLE_RAG_HYBRID_SEARCH, } class QuerySettingsForm(BaseModel): k: Optional[int] = None + r: Optional[float] = None template: Optional[str] = None + hybrid: Optional[bool] = None @app.post("/query/settings/update") @@ -223,13 +369,23 @@ async def update_query_settings( ): app.state.RAG_TEMPLATE = form_data.template if form_data.template else RAG_TEMPLATE app.state.TOP_K = form_data.k if form_data.k else 4 - return {"status": True, "template": app.state.RAG_TEMPLATE} + app.state.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0 + app.state.ENABLE_RAG_HYBRID_SEARCH = form_data.hybrid if form_data.hybrid else False + return { + "status": True, + "template": app.state.RAG_TEMPLATE, + "k": app.state.TOP_K, + "r": app.state.RELEVANCE_THRESHOLD, + "hybrid": app.state.ENABLE_RAG_HYBRID_SEARCH, + } class QueryDocForm(BaseModel): collection_name: str query: str k: Optional[int] = None + r: Optional[float] = None + hybrid: Optional[bool] = None @app.post("/query/doc") @@ -237,14 +393,23 @@ def query_doc_handler( form_data: QueryDocForm, user=Depends(get_current_user), ): - try: - return query_doc( - collection_name=form_data.collection_name, - query=form_data.query, - k=form_data.k if form_data.k else app.state.TOP_K, - embedding_function=app.state.sentence_transformer_ef, - ) + if app.state.ENABLE_RAG_HYBRID_SEARCH: + return query_doc_with_hybrid_search( + collection_name=form_data.collection_name, + query=form_data.query, + embedding_function=app.state.EMBEDDING_FUNCTION, + k=form_data.k if form_data.k else app.state.TOP_K, + reranking_function=app.state.sentence_transformer_rf, + r=form_data.r if form_data.r else app.state.RELEVANCE_THRESHOLD, + ) + else: + return query_doc( + collection_name=form_data.collection_name, + query=form_data.query, + embedding_function=app.state.EMBEDDING_FUNCTION, + k=form_data.k if form_data.k else app.state.TOP_K, + ) except Exception as e: log.exception(e) raise HTTPException( @@ -257,6 +422,8 @@ class QueryCollectionsForm(BaseModel): collection_names: List[str] query: str k: Optional[int] = None + r: Optional[float] = None + hybrid: Optional[bool] = None @app.post("/query/collection") @@ -264,19 +431,36 @@ def query_collection_handler( form_data: QueryCollectionsForm, user=Depends(get_current_user), ): - return query_collection( - collection_names=form_data.collection_names, - query=form_data.query, - k=form_data.k if form_data.k else app.state.TOP_K, - embedding_function=app.state.sentence_transformer_ef, - ) - - -@app.post("/web") -def store_web(form_data: StoreWebForm, user=Depends(get_current_user)): - # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm" try: - loader = WebBaseLoader(form_data.url) + if app.state.ENABLE_RAG_HYBRID_SEARCH: + return query_collection_with_hybrid_search( + collection_names=form_data.collection_names, + query=form_data.query, + embedding_function=app.state.EMBEDDING_FUNCTION, + k=form_data.k if form_data.k else app.state.TOP_K, + reranking_function=app.state.sentence_transformer_rf, + r=form_data.r if form_data.r else app.state.RELEVANCE_THRESHOLD, + ) + else: + return query_collection( + collection_names=form_data.collection_names, + query=form_data.query, + embedding_function=app.state.EMBEDDING_FUNCTION, + k=form_data.k if form_data.k else app.state.TOP_K, + ) + + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + +@app.post("/youtube") +def store_youtube_video(form_data: UrlForm, user=Depends(get_current_user)): + try: + loader = YoutubeLoader.from_youtube_url(form_data.url, add_video_info=False) data = loader.load() collection_name = form_data.collection_name @@ -297,6 +481,62 @@ def store_web(form_data: StoreWebForm, user=Depends(get_current_user)): ) +@app.post("/web") +def store_web(form_data: UrlForm, user=Depends(get_current_user)): + # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm" + try: + loader = get_web_loader(form_data.url) + data = loader.load() + + collection_name = form_data.collection_name + if collection_name == "": + collection_name = calculate_sha256_string(form_data.url)[:63] + + store_data_in_vector_db(data, collection_name, overwrite=True) + return { + "status": True, + "collection_name": collection_name, + "filename": form_data.url, + } + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + +def get_web_loader(url: str): + # Check if the URL is valid + if isinstance(validators.url(url), validators.ValidationError): + raise ValueError(ERROR_MESSAGES.INVALID_URL) + if not ENABLE_LOCAL_WEB_FETCH: + # Local web fetch is disabled, filter out any URLs that resolve to private IP addresses + parsed_url = urllib.parse.urlparse(url) + # Get IPv4 and IPv6 addresses + ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname) + # Check if any of the resolved addresses are private + # This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader + for ip in ipv4_addresses: + if validators.ipv4(ip, private=True): + raise ValueError(ERROR_MESSAGES.INVALID_URL) + for ip in ipv6_addresses: + if validators.ipv6(ip, private=True): + raise ValueError(ERROR_MESSAGES.INVALID_URL) + return WebBaseLoader(url) + + +def resolve_hostname(hostname): + # Get address information + addr_info = socket.getaddrinfo(hostname, None) + + # Extract IP addresses from address information + ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET] + ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6] + + return ipv4_addresses, ipv6_addresses + + def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> bool: text_splitter = RecursiveCharacterTextSplitter( @@ -304,9 +544,11 @@ def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> b chunk_overlap=app.state.CHUNK_OVERLAP, add_start_index=True, ) + docs = text_splitter.split_documents(data) if len(docs) > 0: + log.info(f"store_data_in_vector_db {docs}") return store_docs_in_vector_db(docs, collection_name, overwrite), None else: raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) @@ -325,6 +567,7 @@ def store_text_in_vector_db( def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> bool: + log.info(f"store_docs_in_vector_db {docs} {collection_name}") texts = [doc.page_content for doc in docs] metadatas = [doc.metadata for doc in docs] @@ -336,14 +579,28 @@ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> b log.info(f"deleting existing collection {collection_name}") CHROMA_CLIENT.delete_collection(name=collection_name) - collection = CHROMA_CLIENT.create_collection( - name=collection_name, - embedding_function=app.state.sentence_transformer_ef, + collection = CHROMA_CLIENT.create_collection(name=collection_name) + + embedding_func = get_embedding_function( + app.state.RAG_EMBEDDING_ENGINE, + app.state.RAG_EMBEDDING_MODEL, + app.state.sentence_transformer_ef, + app.state.OPENAI_API_KEY, + app.state.OPENAI_API_BASE_URL, ) - collection.add( - documents=texts, metadatas=metadatas, ids=[str(uuid.uuid1()) for _ in texts] - ) + embedding_texts = list(map(lambda x: x.replace("\n", " "), texts)) + embeddings = embedding_func(embedding_texts) + + for batch in create_batches( + api=CHROMA_CLIENT, + ids=[str(uuid.uuid1()) for _ in texts], + metadatas=metadatas, + embeddings=embeddings, + documents=texts, + ): + collection.add(*batch) + return True except Exception as e: log.exception(e) diff --git a/backend/apps/rag/utils.py b/backend/apps/rag/utils.py index 7b9e6628..b1142e85 100644 --- a/backend/apps/rag/utils.py +++ b/backend/apps/rag/utils.py @@ -1,97 +1,189 @@ -import re +import os import logging +import requests + from typing import List +from apps.ollama.main import ( + generate_ollama_embeddings, + GenerateEmbeddingsForm, +) + +from huggingface_hub import snapshot_download + +from langchain_core.documents import Document +from langchain_community.retrievers import BM25Retriever +from langchain.retrievers import ( + ContextualCompressionRetriever, + EnsembleRetriever, +) + +from typing import Optional from config import SRC_LOG_LEVELS, CHROMA_CLIENT + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -def query_doc(collection_name: str, query: str, k: int, embedding_function): +def query_doc( + collection_name: str, + query: str, + embedding_function, + k: int, +): try: - # if you use docker use the model from the environment variable - collection = CHROMA_CLIENT.get_collection( - name=collection_name, - embedding_function=embedding_function, - ) + collection = CHROMA_CLIENT.get_collection(name=collection_name) + query_embeddings = embedding_function(query) + result = collection.query( - query_texts=[query], + query_embeddings=[query_embeddings], n_results=k, ) + + log.info(f"query_doc:result {result}") return result except Exception as e: raise e -def merge_and_sort_query_results(query_results, k): +def query_doc_with_hybrid_search( + collection_name: str, + query: str, + embedding_function, + k: int, + reranking_function, + r: float, +): + try: + collection = CHROMA_CLIENT.get_collection(name=collection_name) + documents = collection.get() # get all documents + + bm25_retriever = BM25Retriever.from_texts( + texts=documents.get("documents"), + metadatas=documents.get("metadatas"), + ) + bm25_retriever.k = k + + chroma_retriever = ChromaRetriever( + collection=collection, + embedding_function=embedding_function, + top_n=k, + ) + + ensemble_retriever = EnsembleRetriever( + retrievers=[bm25_retriever, chroma_retriever], weights=[0.5, 0.5] + ) + + compressor = RerankCompressor( + embedding_function=embedding_function, + top_n=k, + reranking_function=reranking_function, + r_score=r, + ) + + compression_retriever = ContextualCompressionRetriever( + base_compressor=compressor, base_retriever=ensemble_retriever + ) + + result = compression_retriever.invoke(query) + result = { + "distances": [[d.metadata.get("score") for d in result]], + "documents": [[d.page_content for d in result]], + "metadatas": [[d.metadata for d in result]], + } + + log.info(f"query_doc_with_hybrid_search:result {result}") + return result + except Exception as e: + raise e + + +def merge_and_sort_query_results(query_results, k, reverse=False): # Initialize lists to store combined data - combined_ids = [] combined_distances = [] - combined_metadatas = [] combined_documents = [] + combined_metadatas = [] - # Combine data from each dictionary for data in query_results: - combined_ids.extend(data["ids"][0]) combined_distances.extend(data["distances"][0]) - combined_metadatas.extend(data["metadatas"][0]) combined_documents.extend(data["documents"][0]) + combined_metadatas.extend(data["metadatas"][0]) - # Create a list of tuples (distance, id, metadata, document) - combined = list( - zip(combined_distances, combined_ids, combined_metadatas, combined_documents) - ) + # Create a list of tuples (distance, document, metadata) + combined = list(zip(combined_distances, combined_documents, combined_metadatas)) # Sort the list based on distances - combined.sort(key=lambda x: x[0]) + combined.sort(key=lambda x: x[0], reverse=reverse) - # Unzip the sorted list - sorted_distances, sorted_ids, sorted_metadatas, sorted_documents = zip(*combined) + # We don't have anything :-( + if not combined: + sorted_distances = [] + sorted_documents = [] + sorted_metadatas = [] + else: + # Unzip the sorted list + sorted_distances, sorted_documents, sorted_metadatas = zip(*combined) - # Slicing the lists to include only k elements - sorted_distances = list(sorted_distances)[:k] - sorted_ids = list(sorted_ids)[:k] - sorted_metadatas = list(sorted_metadatas)[:k] - sorted_documents = list(sorted_documents)[:k] + # Slicing the lists to include only k elements + sorted_distances = list(sorted_distances)[:k] + sorted_documents = list(sorted_documents)[:k] + sorted_metadatas = list(sorted_metadatas)[:k] # Create the output dictionary - merged_query_results = { - "ids": [sorted_ids], + result = { "distances": [sorted_distances], - "metadatas": [sorted_metadatas], "documents": [sorted_documents], - "embeddings": None, - "uris": None, - "data": None, + "metadatas": [sorted_metadatas], } - return merged_query_results + return result def query_collection( - collection_names: List[str], query: str, k: int, embedding_function + collection_names: List[str], + query: str, + embedding_function, + k: int, ): - results = [] - for collection_name in collection_names: try: - # if you use docker use the model from the environment variable - collection = CHROMA_CLIENT.get_collection( - name=collection_name, + result = query_doc( + collection_name=collection_name, + query=query, + k=k, embedding_function=embedding_function, ) - - result = collection.query( - query_texts=[query], - n_results=k, - ) results.append(result) except: pass + return merge_and_sort_query_results(results, k=k) - return merge_and_sort_query_results(results, k) + +def query_collection_with_hybrid_search( + collection_names: List[str], + query: str, + embedding_function, + k: int, + reranking_function, + r: float, +): + results = [] + for collection_name in collection_names: + try: + result = query_doc_with_hybrid_search( + collection_name=collection_name, + query=query, + embedding_function=embedding_function, + k=k, + reranking_function=reranking_function, + r=r, + ) + results.append(result) + except: + pass + return merge_and_sort_query_results(results, k=k, reverse=True) def rag_template(template: str, context: str, query: str): @@ -100,8 +192,53 @@ def rag_template(template: str, context: str, query: str): return template -def rag_messages(docs, messages, template, k, embedding_function): - log.debug(f"docs: {docs}") +def get_embedding_function( + embedding_engine, + embedding_model, + embedding_function, + openai_key, + openai_url, +): + if embedding_engine == "": + return lambda query: embedding_function.encode(query).tolist() + elif embedding_engine in ["ollama", "openai"]: + if embedding_engine == "ollama": + func = lambda query: generate_ollama_embeddings( + GenerateEmbeddingsForm( + **{ + "model": embedding_model, + "prompt": query, + } + ) + ) + elif embedding_engine == "openai": + func = lambda query: generate_openai_embeddings( + model=embedding_model, + text=query, + key=openai_key, + url=openai_url, + ) + + def generate_multiple(query, f): + if isinstance(query, list): + return [f(q) for q in query] + else: + return f(query) + + return lambda query: generate_multiple(query, func) + + +def rag_messages( + docs, + messages, + template, + embedding_function, + k, + reranking_function, + r, + hybrid_search, +): + log.debug(f"docs: {docs} {messages} {embedding_function} {reranking_function}") last_user_message_idx = None for i in range(len(messages) - 1, -1, -1): @@ -128,40 +265,69 @@ def rag_messages(docs, messages, template, k, embedding_function): content_type = None query = "" + extracted_collections = [] relevant_contexts = [] for doc in docs: context = None + collection = doc.get("collection_name") + if collection: + collection = [collection] + else: + collection = doc.get("collection_names", []) + + collection = set(collection).difference(extracted_collections) + if not collection: + log.debug(f"skipping {doc} as it has already been extracted") + continue + try: - if doc["type"] == "collection": - context = query_collection( - collection_names=doc["collection_names"], - query=query, - k=k, - embedding_function=embedding_function, - ) - elif doc["type"] == "text": + if doc["type"] == "text": context = doc["content"] else: - context = query_doc( - collection_name=doc["collection_name"], - query=query, - k=k, - embedding_function=embedding_function, - ) + if hybrid_search: + context = query_collection_with_hybrid_search( + collection_names=( + doc["collection_names"] + if doc["type"] == "collection" + else [doc["collection_name"]] + ), + query=query, + embedding_function=embedding_function, + k=k, + reranking_function=reranking_function, + r=r, + ) + else: + context = query_collection( + collection_names=( + doc["collection_names"] + if doc["type"] == "collection" + else [doc["collection_name"]] + ), + query=query, + embedding_function=embedding_function, + k=k, + ) except Exception as e: log.exception(e) context = None - relevant_contexts.append(context) + if context: + relevant_contexts.append(context) - log.debug(f"relevant_contexts: {relevant_contexts}") + extracted_collections.extend(collection) context_string = "" for context in relevant_contexts: - if context: - context_string += " ".join(context["documents"][0]) + "\n" + try: + if "documents" in context: + items = [item for item in context["documents"][0] if item is not None] + context_string += "\n\n".join(items) + except Exception as e: + log.exception(e) + context_string = context_string.strip() ra_content = rag_template( template=template, @@ -169,6 +335,8 @@ def rag_messages(docs, messages, template, k, embedding_function): query=query, ) + log.debug(f"ra_content: {ra_content}") + if content_type == "list": new_content = [] for content_item in user_message["content"]: @@ -188,3 +356,162 @@ def rag_messages(docs, messages, template, k, embedding_function): messages[last_user_message_idx] = new_user_message return messages + + +def get_model_path(model: str, update_model: bool = False): + # Construct huggingface_hub kwargs with local_files_only to return the snapshot path + cache_dir = os.getenv("SENTENCE_TRANSFORMERS_HOME") + + local_files_only = not update_model + + snapshot_kwargs = { + "cache_dir": cache_dir, + "local_files_only": local_files_only, + } + + log.debug(f"model: {model}") + log.debug(f"snapshot_kwargs: {snapshot_kwargs}") + + # Inspiration from upstream sentence_transformers + if ( + os.path.exists(model) + or ("\\" in model or model.count("/") > 1) + and local_files_only + ): + # If fully qualified path exists, return input, else set repo_id + return model + elif "/" not in model: + # Set valid repo_id for model short-name + model = "sentence-transformers" + "/" + model + + snapshot_kwargs["repo_id"] = model + + # Attempt to query the huggingface_hub library to determine the local path and/or to update + try: + model_repo_path = snapshot_download(**snapshot_kwargs) + log.debug(f"model_repo_path: {model_repo_path}") + return model_repo_path + except Exception as e: + log.exception(f"Cannot determine model snapshot path: {e}") + return model + + +def generate_openai_embeddings( + model: str, text: str, key: str, url: str = "https://api.openai.com/v1" +): + try: + r = requests.post( + f"{url}/embeddings", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {key}", + }, + json={"input": text, "model": model}, + ) + r.raise_for_status() + data = r.json() + if "data" in data: + return data["data"][0]["embedding"] + else: + raise "Something went wrong :/" + except Exception as e: + print(e) + return None + + +from typing import Any + +from langchain_core.retrievers import BaseRetriever +from langchain_core.callbacks import CallbackManagerForRetrieverRun + + +class ChromaRetriever(BaseRetriever): + collection: Any + embedding_function: Any + top_n: int + + def _get_relevant_documents( + self, + query: str, + *, + run_manager: CallbackManagerForRetrieverRun, + ) -> List[Document]: + query_embeddings = self.embedding_function(query) + + results = self.collection.query( + query_embeddings=[query_embeddings], + n_results=self.top_n, + ) + + ids = results["ids"][0] + metadatas = results["metadatas"][0] + documents = results["documents"][0] + + results = [] + for idx in range(len(ids)): + results.append( + Document( + metadata=metadatas[idx], + page_content=documents[idx], + ) + ) + return results + + +import operator + +from typing import Optional, Sequence + +from langchain_core.documents import BaseDocumentCompressor, Document +from langchain_core.callbacks import Callbacks +from langchain_core.pydantic_v1 import Extra + +from sentence_transformers import util + + +class RerankCompressor(BaseDocumentCompressor): + embedding_function: Any + top_n: int + reranking_function: Any + r_score: float + + class Config: + extra = Extra.forbid + arbitrary_types_allowed = True + + def compress_documents( + self, + documents: Sequence[Document], + query: str, + callbacks: Optional[Callbacks] = None, + ) -> Sequence[Document]: + reranking = self.reranking_function is not None + + if reranking: + scores = self.reranking_function.predict( + [(query, doc.page_content) for doc in documents] + ) + else: + query_embedding = self.embedding_function(query) + document_embedding = self.embedding_function( + [doc.page_content for doc in documents] + ) + scores = util.cos_sim(query_embedding, document_embedding)[0] + + docs_with_scores = list(zip(documents, scores.tolist())) + if self.r_score: + docs_with_scores = [ + (d, s) for d, s in docs_with_scores if s >= self.r_score + ] + + result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True) + final_results = [] + for doc, doc_score in result[: self.top_n]: + metadata = doc.metadata + metadata["score"] = doc_score + doc = Document( + page_content=doc.page_content, + metadata=metadata, + ) + final_results.append(doc) + return final_results diff --git a/backend/apps/web/internal/db.py b/backend/apps/web/internal/db.py index fad566ce..136e3faf 100644 --- a/backend/apps/web/internal/db.py +++ b/backend/apps/web/internal/db.py @@ -1,6 +1,7 @@ from peewee import * from peewee_migrate import Router -from config import SRC_LOG_LEVELS, DATA_DIR +from playhouse.db_url import connect +from config import SRC_LOG_LEVELS, DATA_DIR, DATABASE_URL import os import logging @@ -11,12 +12,12 @@ log.setLevel(SRC_LOG_LEVELS["DB"]) if os.path.exists(f"{DATA_DIR}/ollama.db"): # Rename the file os.rename(f"{DATA_DIR}/ollama.db", f"{DATA_DIR}/webui.db") - log.info("File renamed successfully.") + log.info("Database migrated from Ollama-WebUI successfully.") else: pass - -DB = SqliteDatabase(f"{DATA_DIR}/webui.db") +DB = connect(DATABASE_URL) +log.info(f"Connected to a {DB.__class__.__name__} database.") router = Router(DB, migrate_dir="apps/web/internal/migrations", logger=log) router.run() DB.connect(reuse_if_open=True) diff --git a/backend/apps/web/internal/migrations/001_initial_schema.py b/backend/apps/web/internal/migrations/001_initial_schema.py index 24ea6d39..93f278f1 100644 --- a/backend/apps/web/internal/migrations/001_initial_schema.py +++ b/backend/apps/web/internal/migrations/001_initial_schema.py @@ -37,6 +37,18 @@ with suppress(ImportError): def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" + # We perform different migrations for SQLite and other databases + # This is because SQLite is very loose with enforcing its schema, and trying to migrate other databases like SQLite + # will require per-database SQL queries. + # Instead, we assume that because external DB support was added at a later date, it is safe to assume a newer base + # schema instead of trying to migrate from an older schema. + if isinstance(database, pw.SqliteDatabase): + migrate_sqlite(migrator, database, fake=fake) + else: + migrate_external(migrator, database, fake=fake) + + +def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False): @migrator.create_model class Auth(pw.Model): id = pw.CharField(max_length=255, unique=True) @@ -53,7 +65,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): user_id = pw.CharField(max_length=255) title = pw.CharField() chat = pw.TextField() - timestamp = pw.DateField() + timestamp = pw.BigIntegerField() class Meta: table_name = "chat" @@ -64,7 +76,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): tag_name = pw.CharField(max_length=255) chat_id = pw.CharField(max_length=255) user_id = pw.CharField(max_length=255) - timestamp = pw.DateField() + timestamp = pw.BigIntegerField() class Meta: table_name = "chatidtag" @@ -78,7 +90,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): filename = pw.CharField() content = pw.TextField(null=True) user_id = pw.CharField(max_length=255) - timestamp = pw.DateField() + timestamp = pw.BigIntegerField() class Meta: table_name = "document" @@ -89,7 +101,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): tag_name = pw.CharField(max_length=255, unique=True) user_id = pw.CharField(max_length=255) modelfile = pw.TextField() - timestamp = pw.DateField() + timestamp = pw.BigIntegerField() class Meta: table_name = "modelfile" @@ -101,7 +113,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): user_id = pw.CharField(max_length=255) title = pw.CharField() content = pw.TextField() - timestamp = pw.DateField() + timestamp = pw.BigIntegerField() class Meta: table_name = "prompt" @@ -123,7 +135,100 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): email = pw.CharField(max_length=255) role = pw.CharField(max_length=255) profile_image_url = pw.CharField(max_length=255) - timestamp = pw.DateField() + timestamp = pw.BigIntegerField() + + class Meta: + table_name = "user" + + +def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False): + @migrator.create_model + class Auth(pw.Model): + id = pw.CharField(max_length=255, unique=True) + email = pw.CharField(max_length=255) + password = pw.TextField() + active = pw.BooleanField() + + class Meta: + table_name = "auth" + + @migrator.create_model + class Chat(pw.Model): + id = pw.CharField(max_length=255, unique=True) + user_id = pw.CharField(max_length=255) + title = pw.TextField() + chat = pw.TextField() + timestamp = pw.BigIntegerField() + + class Meta: + table_name = "chat" + + @migrator.create_model + class ChatIdTag(pw.Model): + id = pw.CharField(max_length=255, unique=True) + tag_name = pw.CharField(max_length=255) + chat_id = pw.CharField(max_length=255) + user_id = pw.CharField(max_length=255) + timestamp = pw.BigIntegerField() + + class Meta: + table_name = "chatidtag" + + @migrator.create_model + class Document(pw.Model): + id = pw.AutoField() + collection_name = pw.CharField(max_length=255, unique=True) + name = pw.CharField(max_length=255, unique=True) + title = pw.TextField() + filename = pw.TextField() + content = pw.TextField(null=True) + user_id = pw.CharField(max_length=255) + timestamp = pw.BigIntegerField() + + class Meta: + table_name = "document" + + @migrator.create_model + class Modelfile(pw.Model): + id = pw.AutoField() + tag_name = pw.CharField(max_length=255, unique=True) + user_id = pw.CharField(max_length=255) + modelfile = pw.TextField() + timestamp = pw.BigIntegerField() + + class Meta: + table_name = "modelfile" + + @migrator.create_model + class Prompt(pw.Model): + id = pw.AutoField() + command = pw.CharField(max_length=255, unique=True) + user_id = pw.CharField(max_length=255) + title = pw.TextField() + content = pw.TextField() + timestamp = pw.BigIntegerField() + + class Meta: + table_name = "prompt" + + @migrator.create_model + class Tag(pw.Model): + id = pw.CharField(max_length=255, unique=True) + name = pw.CharField(max_length=255) + user_id = pw.CharField(max_length=255) + data = pw.TextField(null=True) + + class Meta: + table_name = "tag" + + @migrator.create_model + class User(pw.Model): + id = pw.CharField(max_length=255, unique=True) + name = pw.CharField(max_length=255) + email = pw.CharField(max_length=255) + role = pw.CharField(max_length=255) + profile_image_url = pw.TextField() + timestamp = pw.BigIntegerField() class Meta: table_name = "user" diff --git a/backend/apps/web/internal/migrations/004_add_archived.py b/backend/apps/web/internal/migrations/004_add_archived.py new file mode 100644 index 00000000..d01c06b4 --- /dev/null +++ b/backend/apps/web/internal/migrations/004_add_archived.py @@ -0,0 +1,46 @@ +"""Peewee migrations -- 002_add_local_sharing.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + migrator.add_fields("chat", archived=pw.BooleanField(default=False)) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_fields("chat", "archived") diff --git a/backend/apps/web/internal/migrations/005_add_updated_at.py b/backend/apps/web/internal/migrations/005_add_updated_at.py new file mode 100644 index 00000000..950866ef --- /dev/null +++ b/backend/apps/web/internal/migrations/005_add_updated_at.py @@ -0,0 +1,130 @@ +"""Peewee migrations -- 002_add_local_sharing.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + if isinstance(database, pw.SqliteDatabase): + migrate_sqlite(migrator, database, fake=fake) + else: + migrate_external(migrator, database, fake=fake) + + +def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False): + # Adding fields created_at and updated_at to the 'chat' table + migrator.add_fields( + "chat", + created_at=pw.DateTimeField(null=True), # Allow null for transition + updated_at=pw.DateTimeField(null=True), # Allow null for transition + ) + + # Populate the new fields from an existing 'timestamp' field + migrator.sql( + "UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL" + ) + + # Now that the data has been copied, remove the original 'timestamp' field + migrator.remove_fields("chat", "timestamp") + + # Update the fields to be not null now that they are populated + migrator.change_fields( + "chat", + created_at=pw.DateTimeField(null=False), + updated_at=pw.DateTimeField(null=False), + ) + + +def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False): + # Adding fields created_at and updated_at to the 'chat' table + migrator.add_fields( + "chat", + created_at=pw.BigIntegerField(null=True), # Allow null for transition + updated_at=pw.BigIntegerField(null=True), # Allow null for transition + ) + + # Populate the new fields from an existing 'timestamp' field + migrator.sql( + "UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL" + ) + + # Now that the data has been copied, remove the original 'timestamp' field + migrator.remove_fields("chat", "timestamp") + + # Update the fields to be not null now that they are populated + migrator.change_fields( + "chat", + created_at=pw.BigIntegerField(null=False), + updated_at=pw.BigIntegerField(null=False), + ) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + if isinstance(database, pw.SqliteDatabase): + rollback_sqlite(migrator, database, fake=fake) + else: + rollback_external(migrator, database, fake=fake) + + +def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False): + # Recreate the timestamp field initially allowing null values for safe transition + migrator.add_fields("chat", timestamp=pw.DateTimeField(null=True)) + + # Copy the earliest created_at date back into the new timestamp field + # This assumes created_at was originally a copy of timestamp + migrator.sql("UPDATE chat SET timestamp = created_at") + + # Remove the created_at and updated_at fields + migrator.remove_fields("chat", "created_at", "updated_at") + + # Finally, alter the timestamp field to not allow nulls if that was the original setting + migrator.change_fields("chat", timestamp=pw.DateTimeField(null=False)) + + +def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False): + # Recreate the timestamp field initially allowing null values for safe transition + migrator.add_fields("chat", timestamp=pw.BigIntegerField(null=True)) + + # Copy the earliest created_at date back into the new timestamp field + # This assumes created_at was originally a copy of timestamp + migrator.sql("UPDATE chat SET timestamp = created_at") + + # Remove the created_at and updated_at fields + migrator.remove_fields("chat", "created_at", "updated_at") + + # Finally, alter the timestamp field to not allow nulls if that was the original setting + migrator.change_fields("chat", timestamp=pw.BigIntegerField(null=False)) diff --git a/backend/apps/web/internal/migrations/006_migrate_timestamps_and_charfields.py b/backend/apps/web/internal/migrations/006_migrate_timestamps_and_charfields.py new file mode 100644 index 00000000..caca14d3 --- /dev/null +++ b/backend/apps/web/internal/migrations/006_migrate_timestamps_and_charfields.py @@ -0,0 +1,130 @@ +"""Peewee migrations -- 006_migrate_timestamps_and_charfields.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + # Alter the tables with timestamps + migrator.change_fields( + "chatidtag", + timestamp=pw.BigIntegerField(), + ) + migrator.change_fields( + "document", + timestamp=pw.BigIntegerField(), + ) + migrator.change_fields( + "modelfile", + timestamp=pw.BigIntegerField(), + ) + migrator.change_fields( + "prompt", + timestamp=pw.BigIntegerField(), + ) + migrator.change_fields( + "user", + timestamp=pw.BigIntegerField(), + ) + # Alter the tables with varchar to text where necessary + migrator.change_fields( + "auth", + password=pw.TextField(), + ) + migrator.change_fields( + "chat", + title=pw.TextField(), + ) + migrator.change_fields( + "document", + title=pw.TextField(), + filename=pw.TextField(), + ) + migrator.change_fields( + "prompt", + title=pw.TextField(), + ) + migrator.change_fields( + "user", + profile_image_url=pw.TextField(), + ) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + if isinstance(database, pw.SqliteDatabase): + # Alter the tables with timestamps + migrator.change_fields( + "chatidtag", + timestamp=pw.DateField(), + ) + migrator.change_fields( + "document", + timestamp=pw.DateField(), + ) + migrator.change_fields( + "modelfile", + timestamp=pw.DateField(), + ) + migrator.change_fields( + "prompt", + timestamp=pw.DateField(), + ) + migrator.change_fields( + "user", + timestamp=pw.DateField(), + ) + migrator.change_fields( + "auth", + password=pw.CharField(max_length=255), + ) + migrator.change_fields( + "chat", + title=pw.CharField(), + ) + migrator.change_fields( + "document", + title=pw.CharField(), + filename=pw.CharField(), + ) + migrator.change_fields( + "prompt", + title=pw.CharField(), + ) + migrator.change_fields( + "user", + profile_image_url=pw.CharField(), + ) diff --git a/backend/apps/web/internal/migrations/007_add_user_last_active_at.py b/backend/apps/web/internal/migrations/007_add_user_last_active_at.py new file mode 100644 index 00000000..dd176ba7 --- /dev/null +++ b/backend/apps/web/internal/migrations/007_add_user_last_active_at.py @@ -0,0 +1,79 @@ +"""Peewee migrations -- 002_add_local_sharing.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + # Adding fields created_at and updated_at to the 'user' table + migrator.add_fields( + "user", + created_at=pw.BigIntegerField(null=True), # Allow null for transition + updated_at=pw.BigIntegerField(null=True), # Allow null for transition + last_active_at=pw.BigIntegerField(null=True), # Allow null for transition + ) + + # Populate the new fields from an existing 'timestamp' field + migrator.sql( + 'UPDATE "user" SET created_at = timestamp, updated_at = timestamp, last_active_at = timestamp WHERE timestamp IS NOT NULL' + ) + + # Now that the data has been copied, remove the original 'timestamp' field + migrator.remove_fields("user", "timestamp") + + # Update the fields to be not null now that they are populated + migrator.change_fields( + "user", + created_at=pw.BigIntegerField(null=False), + updated_at=pw.BigIntegerField(null=False), + last_active_at=pw.BigIntegerField(null=False), + ) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + # Recreate the timestamp field initially allowing null values for safe transition + migrator.add_fields("user", timestamp=pw.BigIntegerField(null=True)) + + # Copy the earliest created_at date back into the new timestamp field + # This assumes created_at was originally a copy of timestamp + migrator.sql('UPDATE "user" SET timestamp = created_at') + + # Remove the created_at and updated_at fields + migrator.remove_fields("user", "created_at", "updated_at", "last_active_at") + + # Finally, alter the timestamp field to not allow nulls if that was the original setting + migrator.change_fields("user", timestamp=pw.BigIntegerField(null=False)) diff --git a/backend/apps/web/models/auths.py b/backend/apps/web/models/auths.py index 06986503..dfa0c439 100644 --- a/backend/apps/web/models/auths.py +++ b/backend/apps/web/models/auths.py @@ -23,7 +23,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Auth(Model): id = CharField(unique=True) email = CharField() - password = CharField() + password = TextField() active = BooleanField() class Meta: @@ -86,6 +86,11 @@ class SignupForm(BaseModel): name: str email: str password: str + profile_image_url: Optional[str] = "/user.png" + + +class AddUserForm(SignupForm): + role: Optional[str] = "pending" class AuthsTable: @@ -94,7 +99,12 @@ class AuthsTable: self.db.create_tables([Auth]) def insert_new_auth( - self, email: str, password: str, name: str, role: str = "pending" + self, + email: str, + password: str, + name: str, + profile_image_url: str = "/user.png", + role: str = "pending", ) -> Optional[UserModel]: log.info("insert_new_auth") @@ -105,7 +115,7 @@ class AuthsTable: ) result = Auth.create(**auth.model_dump()) - user = Users.insert_new_user(id, name, email, role) + user = Users.insert_new_user(id, name, email, profile_image_url, role) if result and user: return user diff --git a/backend/apps/web/models/chats.py b/backend/apps/web/models/chats.py index 95a673cb..891151b9 100644 --- a/backend/apps/web/models/chats.py +++ b/backend/apps/web/models/chats.py @@ -17,10 +17,14 @@ from apps.web.internal.db import DB class Chat(Model): id = CharField(unique=True) user_id = CharField() - title = CharField() + title = TextField() chat = TextField() # Save Chat JSON as Text - timestamp = DateField() + + created_at = BigIntegerField() + updated_at = BigIntegerField() + share_id = CharField(null=True, unique=True) + archived = BooleanField(default=False) class Meta: database = DB @@ -31,8 +35,12 @@ class ChatModel(BaseModel): user_id: str title: str chat: str - timestamp: int # timestamp in epoch + + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + share_id: Optional[str] = None + archived: bool = False #################### @@ -53,13 +61,17 @@ class ChatResponse(BaseModel): user_id: str title: str chat: dict - timestamp: int # timestamp in epoch + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch share_id: Optional[str] = None # id of the chat to be shared + archived: bool class ChatTitleIdResponse(BaseModel): id: str title: str + updated_at: int + created_at: int class ChatTable: @@ -77,7 +89,8 @@ class ChatTable: form_data.chat["title"] if "title" in form_data.chat else "New Chat" ), "chat": json.dumps(form_data.chat), - "timestamp": int(time.time()), + "created_at": int(time.time()), + "updated_at": int(time.time()), } ) @@ -89,7 +102,7 @@ class ChatTable: query = Chat.update( chat=json.dumps(chat), title=chat["title"] if "title" in chat else "New Chat", - timestamp=int(time.time()), + updated_at=int(time.time()), ).where(Chat.id == id) query.execute() @@ -111,7 +124,8 @@ class ChatTable: "user_id": f"shared-{chat_id}", "title": chat.title, "chat": chat.chat, - "timestamp": int(time.time()), + "created_at": chat.created_at, + "updated_at": int(time.time()), } ) shared_result = Chat.create(**shared_chat.model_dump()) @@ -163,40 +177,55 @@ class ChatTable: except: return None - def get_chat_lists_by_user_id( + def toggle_chat_archive_by_id(self, id: str) -> Optional[ChatModel]: + try: + chat = self.get_chat_by_id(id) + query = Chat.update( + archived=(not chat.archived), + ).where(Chat.id == id) + + query.execute() + + chat = Chat.get(Chat.id == id) + return ChatModel(**model_to_dict(chat)) + except: + return None + + def get_archived_chat_list_by_user_id( self, user_id: str, skip: int = 0, limit: int = 50 ) -> List[ChatModel]: return [ ChatModel(**model_to_dict(chat)) for chat in Chat.select() + .where(Chat.archived == True) .where(Chat.user_id == user_id) - .order_by(Chat.timestamp.desc()) + .order_by(Chat.updated_at.desc()) # .limit(limit) # .offset(skip) ] - def get_chat_lists_by_chat_ids( + def get_chat_list_by_user_id( + self, user_id: str, skip: int = 0, limit: int = 50 + ) -> List[ChatModel]: + return [ + ChatModel(**model_to_dict(chat)) + for chat in Chat.select() + .where(Chat.archived == False) + .where(Chat.user_id == user_id) + .order_by(Chat.updated_at.desc()) + # .limit(limit) + # .offset(skip) + ] + + def get_chat_list_by_chat_ids( self, chat_ids: List[str], skip: int = 0, limit: int = 50 ) -> List[ChatModel]: return [ ChatModel(**model_to_dict(chat)) for chat in Chat.select() + .where(Chat.archived == False) .where(Chat.id.in_(chat_ids)) - .order_by(Chat.timestamp.desc()) - ] - - def get_all_chats(self) -> List[ChatModel]: - return [ - ChatModel(**model_to_dict(chat)) - for chat in Chat.select().order_by(Chat.timestamp.desc()) - ] - - def get_all_chats_by_user_id(self, user_id: str) -> List[ChatModel]: - return [ - ChatModel(**model_to_dict(chat)) - for chat in Chat.select() - .where(Chat.user_id == user_id) - .order_by(Chat.timestamp.desc()) + .order_by(Chat.updated_at.desc()) ] def get_chat_by_id(self, id: str) -> Optional[ChatModel]: @@ -206,6 +235,18 @@ class ChatTable: except: return None + def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]: + try: + chat = Chat.get(Chat.share_id == id) + + if chat: + chat = Chat.get(Chat.id == id) + return ChatModel(**model_to_dict(chat)) + else: + return None + except: + return None + def get_chat_by_id_and_user_id(self, id: str, user_id: str) -> Optional[ChatModel]: try: chat = Chat.get(Chat.id == id, Chat.user_id == user_id) @@ -216,9 +257,28 @@ class ChatTable: def get_chats(self, skip: int = 0, limit: int = 50) -> List[ChatModel]: return [ ChatModel(**model_to_dict(chat)) - for chat in Chat.select().limit(limit).offset(skip) + for chat in Chat.select().order_by(Chat.updated_at.desc()) + # .limit(limit).offset(skip) ] + def get_chats_by_user_id(self, user_id: str) -> List[ChatModel]: + return [ + ChatModel(**model_to_dict(chat)) + for chat in Chat.select() + .where(Chat.user_id == user_id) + .order_by(Chat.updated_at.desc()) + # .limit(limit).offset(skip) + ] + + def delete_chat_by_id(self, id: str) -> bool: + try: + query = Chat.delete().where((Chat.id == id)) + query.execute() # Remove the rows, return number of rows removed. + + return True and self.delete_shared_chat_by_chat_id(id) + except: + return False + def delete_chat_by_id_and_user_id(self, id: str, user_id: str) -> bool: try: query = Chat.delete().where((Chat.id == id) & (Chat.user_id == user_id)) diff --git a/backend/apps/web/models/documents.py b/backend/apps/web/models/documents.py index 91e721a4..42b99596 100644 --- a/backend/apps/web/models/documents.py +++ b/backend/apps/web/models/documents.py @@ -25,11 +25,11 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Document(Model): collection_name = CharField(unique=True) name = CharField(unique=True) - title = CharField() - filename = CharField() + title = TextField() + filename = TextField() content = TextField(null=True) user_id = CharField() - timestamp = DateField() + timestamp = BigIntegerField() class Meta: database = DB diff --git a/backend/apps/web/models/modelfiles.py b/backend/apps/web/models/modelfiles.py index 50439a80..1d60d7c5 100644 --- a/backend/apps/web/models/modelfiles.py +++ b/backend/apps/web/models/modelfiles.py @@ -20,7 +20,7 @@ class Modelfile(Model): tag_name = CharField(unique=True) user_id = CharField() modelfile = TextField() - timestamp = DateField() + timestamp = BigIntegerField() class Meta: database = DB diff --git a/backend/apps/web/models/prompts.py b/backend/apps/web/models/prompts.py index e6b663c0..bc4e3e58 100644 --- a/backend/apps/web/models/prompts.py +++ b/backend/apps/web/models/prompts.py @@ -19,9 +19,9 @@ import json class Prompt(Model): command = CharField(unique=True) user_id = CharField() - title = CharField() + title = TextField() content = TextField() - timestamp = DateField() + timestamp = BigIntegerField() class Meta: database = DB diff --git a/backend/apps/web/models/tags.py b/backend/apps/web/models/tags.py index 196551b7..d9a967ff 100644 --- a/backend/apps/web/models/tags.py +++ b/backend/apps/web/models/tags.py @@ -35,7 +35,7 @@ class ChatIdTag(Model): tag_name = CharField() chat_id = CharField() user_id = CharField() - timestamp = DateField() + timestamp = BigIntegerField() class Meta: database = DB @@ -136,7 +136,9 @@ class TagTable: return [ TagModel(**model_to_dict(tag)) - for tag in Tag.select().where(Tag.name.in_(tag_names)) + for tag in Tag.select() + .where(Tag.user_id == user_id) + .where(Tag.name.in_(tag_names)) ] def get_tags_by_chat_id_and_user_id( @@ -151,7 +153,9 @@ class TagTable: return [ TagModel(**model_to_dict(tag)) - for tag in Tag.select().where(Tag.name.in_(tag_names)) + for tag in Tag.select() + .where(Tag.user_id == user_id) + .where(Tag.name.in_(tag_names)) ] def get_chat_ids_by_tag_name_and_user_id( diff --git a/backend/apps/web/models/users.py b/backend/apps/web/models/users.py index a01e595e..450dd918 100644 --- a/backend/apps/web/models/users.py +++ b/backend/apps/web/models/users.py @@ -18,8 +18,12 @@ class User(Model): name = CharField() email = CharField() role = CharField() - profile_image_url = CharField() - timestamp = DateField() + profile_image_url = TextField() + + last_active_at = BigIntegerField() + updated_at = BigIntegerField() + created_at = BigIntegerField() + api_key = CharField(null=True, unique=True) class Meta: @@ -31,8 +35,12 @@ class UserModel(BaseModel): name: str email: str role: str = "pending" - profile_image_url: str = "/user.png" - timestamp: int # timestamp in epoch + profile_image_url: str + + last_active_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + api_key: Optional[str] = None @@ -59,7 +67,12 @@ class UsersTable: self.db.create_tables([User]) def insert_new_user( - self, id: str, name: str, email: str, role: str = "pending" + self, + id: str, + name: str, + email: str, + profile_image_url: str = "/user.png", + role: str = "pending", ) -> Optional[UserModel]: user = UserModel( **{ @@ -67,8 +80,10 @@ class UsersTable: "name": name, "email": email, "role": role, - "profile_image_url": "/user.png", - "timestamp": int(time.time()), + "profile_image_url": profile_image_url, + "last_active_at": int(time.time()), + "created_at": int(time.time()), + "updated_at": int(time.time()), } ) result = User.create(**user.model_dump()) @@ -108,6 +123,13 @@ class UsersTable: def get_num_users(self) -> Optional[int]: return User.select().count() + def get_first_user(self) -> UserModel: + try: + user = User.select().order_by(User.created_at).first() + return UserModel(**model_to_dict(user)) + except: + return None + def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]: try: query = User.update(role=role).where(User.id == id) @@ -132,6 +154,16 @@ class UsersTable: except: return None + def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]: + try: + query = User.update(last_active_at=int(time.time())).where(User.id == id) + query.execute() + + user = User.get(User.id == id) + return UserModel(**model_to_dict(user)) + except: + return None + def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]: try: query = User.update(**updated).where(User.id == id) diff --git a/backend/apps/web/routers/auths.py b/backend/apps/web/routers/auths.py index 293cb55b..01fddb73 100644 --- a/backend/apps/web/routers/auths.py +++ b/backend/apps/web/routers/auths.py @@ -1,14 +1,19 @@ -from fastapi import Request +import logging + +from fastapi import Request, UploadFile, File from fastapi import Depends, HTTPException, status from fastapi import APIRouter from pydantic import BaseModel import re import uuid +import csv + from apps.web.models.auths import ( SigninForm, SignupForm, + AddUserForm, UpdateProfileForm, UpdatePasswordForm, UserResponse, @@ -163,7 +168,11 @@ async def signup(request: Request, form_data: SignupForm): ) hashed = get_password_hash(form_data.password) user = Auths.insert_new_auth( - form_data.email.lower(), hashed, form_data.name, role + form_data.email.lower(), + hashed, + form_data.name, + form_data.profile_image_url, + role, ) if user: @@ -199,6 +208,51 @@ async def signup(request: Request, form_data: SignupForm): raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) +############################ +# AddUser +############################ + + +@router.post("/add", response_model=SigninResponse) +async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): + + if not validate_email_format(form_data.email.lower()): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT + ) + + if Users.get_user_by_email(form_data.email.lower()): + raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) + + try: + + print(form_data) + hashed = get_password_hash(form_data.password) + user = Auths.insert_new_auth( + form_data.email.lower(), + hashed, + form_data.name, + form_data.profile_image_url, + form_data.role, + ) + + if user: + token = create_token(data={"id": user.id}) + return { + "token": token, + "token_type": "Bearer", + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + "profile_image_url": user.profile_image_url, + } + else: + raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) + except Exception as err: + raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + + ############################ # ToggleSignUp ############################ diff --git a/backend/apps/web/routers/chats.py b/backend/apps/web/routers/chats.py index 660a0d7f..73b15e04 100644 --- a/backend/apps/web/routers/chats.py +++ b/backend/apps/web/routers/chats.py @@ -28,7 +28,7 @@ from apps.web.models.tags import ( from constants import ERROR_MESSAGES -from config import SRC_LOG_LEVELS +from config import SRC_LOG_LEVELS, ENABLE_ADMIN_EXPORT log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -36,27 +36,73 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() ############################ -# GetChats +# GetChatList ############################ @router.get("/", response_model=List[ChatTitleIdResponse]) -async def get_user_chats( +@router.get("/list", response_model=List[ChatTitleIdResponse]) +async def get_session_user_chat_list( user=Depends(get_current_user), skip: int = 0, limit: int = 50 ): - return Chats.get_chat_lists_by_user_id(user.id, skip, limit) + return Chats.get_chat_list_by_user_id(user.id, skip, limit) ############################ -# GetAllChats +# DeleteAllChats +############################ + + +@router.delete("/", response_model=bool) +async def delete_all_user_chats(request: Request, user=Depends(get_current_user)): + + if ( + user.role == "user" + and not request.app.state.USER_PERMISSIONS["chat"]["deletion"] + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + result = Chats.delete_chats_by_user_id(user.id) + return result + + +############################ +# GetUserChatList +############################ + + +@router.get("/list/user/{user_id}", response_model=List[ChatTitleIdResponse]) +async def get_user_chat_list_by_user_id( + user_id: str, user=Depends(get_admin_user), skip: int = 0, limit: int = 50 +): + return Chats.get_chat_list_by_user_id(user_id, skip, limit) + + +############################ +# GetArchivedChats +############################ + + +@router.get("/archived", response_model=List[ChatTitleIdResponse]) +async def get_archived_session_user_chat_list( + user=Depends(get_current_user), skip: int = 0, limit: int = 50 +): + return Chats.get_archived_chat_list_by_user_id(user.id, skip, limit) + + +############################ +# GetChats ############################ @router.get("/all", response_model=List[ChatResponse]) -async def get_all_user_chats(user=Depends(get_current_user)): +async def get_user_chats(user=Depends(get_current_user)): return [ ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)}) - for chat in Chats.get_all_chats_by_user_id(user.id) + for chat in Chats.get_chats_by_user_id(user.id) ] @@ -67,9 +113,14 @@ async def get_all_user_chats(user=Depends(get_current_user)): @router.get("/all/db", response_model=List[ChatResponse]) async def get_all_user_chats_in_db(user=Depends(get_admin_user)): + if not ENABLE_ADMIN_EXPORT: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) return [ ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)}) - for chat in Chats.get_all_chats() + for chat in Chats.get_chats() ] @@ -90,45 +141,6 @@ async def create_new_chat(form_data: ChatForm, user=Depends(get_current_user)): ) -############################ -# GetAllTags -############################ - - -@router.get("/tags/all", response_model=List[TagModel]) -async def get_all_tags(user=Depends(get_current_user)): - try: - tags = Tags.get_tags_by_user_id(user.id) - return tags - except Exception as e: - log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) - - -############################ -# GetChatsByTags -############################ - - -@router.get("/tags/tag/{tag_name}", response_model=List[ChatTitleIdResponse]) -async def get_user_chats_by_tag_name( - tag_name: str, user=Depends(get_current_user), skip: int = 0, limit: int = 50 -): - chat_ids = [ - chat_id_tag.chat_id - for chat_id_tag in Tags.get_chat_ids_by_tag_name_and_user_id(tag_name, user.id) - ] - - chats = Chats.get_chat_lists_by_chat_ids(chat_ids, skip, limit) - - if len(chats) == 0: - Tags.delete_tag_by_tag_name_and_user_id(tag_name, user.id) - - return chats - - ############################ # GetChatById ############################ @@ -176,17 +188,35 @@ async def update_chat_by_id( @router.delete("/{id}", response_model=bool) async def delete_chat_by_id(request: Request, id: str, user=Depends(get_current_user)): - if ( - user.role == "user" - and not request.app.state.USER_PERMISSIONS["chat"]["deletion"] - ): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + if user.role == "admin": + result = Chats.delete_chat_by_id(id) + return result + else: + if not request.app.state.USER_PERMISSIONS["chat"]["deletion"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) - result = Chats.delete_chat_by_id_and_user_id(id, user.id) - return result + result = Chats.delete_chat_by_id_and_user_id(id, user.id) + return result + + +############################ +# ArchiveChat +############################ + + +@router.get("/{id}/archive", response_model=Optional[ChatResponse]) +async def archive_chat_by_id(id: str, user=Depends(get_current_user)): + chat = Chats.get_chat_by_id_and_user_id(id, user.id) + if chat: + chat = Chats.toggle_chat_archive_by_id(id) + return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)}) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() + ) ############################ @@ -251,7 +281,15 @@ async def delete_shared_chat_by_id(id: str, user=Depends(get_current_user)): @router.get("/share/{share_id}", response_model=Optional[ChatResponse]) async def get_shared_chat_by_id(share_id: str, user=Depends(get_current_user)): - chat = Chats.get_chat_by_id(share_id) + if user.role == "pending": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if user.role == "user": + chat = Chats.get_chat_by_share_id(share_id) + elif user.role == "admin": + chat = Chats.get_chat_by_id(share_id) if chat: return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)}) @@ -261,6 +299,45 @@ async def get_shared_chat_by_id(share_id: str, user=Depends(get_current_user)): ) +############################ +# GetAllTags +############################ + + +@router.get("/tags/all", response_model=List[TagModel]) +async def get_all_tags(user=Depends(get_current_user)): + try: + tags = Tags.get_tags_by_user_id(user.id) + return tags + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + +############################ +# GetChatsByTags +############################ + + +@router.get("/tags/tag/{tag_name}", response_model=List[ChatTitleIdResponse]) +async def get_user_chat_list_by_tag_name( + tag_name: str, user=Depends(get_current_user), skip: int = 0, limit: int = 50 +): + chat_ids = [ + chat_id_tag.chat_id + for chat_id_tag in Tags.get_chat_ids_by_tag_name_and_user_id(tag_name, user.id) + ] + + chats = Chats.get_chat_list_by_chat_ids(chat_ids, skip, limit) + + if len(chats) == 0: + Tags.delete_tag_by_tag_name_and_user_id(tag_name, user.id) + + return chats + + ############################ # GetChatTagsById ############################ @@ -341,24 +418,3 @@ async def delete_all_chat_tags_by_id(id: str, user=Depends(get_current_user)): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND ) - - -############################ -# DeleteAllChats -############################ - - -@router.delete("/", response_model=bool) -async def delete_all_user_chats(request: Request, user=Depends(get_current_user)): - - if ( - user.role == "user" - and not request.app.state.USER_PERMISSIONS["chat"]["deletion"] - ): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - - result = Chats.delete_chats_by_user_id(user.id) - return result diff --git a/backend/apps/web/routers/users.py b/backend/apps/web/routers/users.py index 6f1020ec..59f6c21b 100644 --- a/backend/apps/web/routers/users.py +++ b/backend/apps/web/routers/users.py @@ -58,7 +58,7 @@ async def update_user_permissions( @router.post("/update/role", response_model=Optional[UserModel]) async def update_user_role(form_data: UserRoleUpdateForm, user=Depends(get_admin_user)): - if user.id != form_data.id: + if user.id != form_data.id and form_data.id != Users.get_first_user().id: return Users.update_user_role_by_id(form_data.id, form_data.role) raise HTTPException( diff --git a/backend/apps/web/routers/utils.py b/backend/apps/web/routers/utils.py index 0ee75cfe..12805873 100644 --- a/backend/apps/web/routers/utils.py +++ b/backend/apps/web/routers/utils.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, UploadFile, File, Response from fastapi import Depends, HTTPException, status +from peewee import SqliteDatabase from starlette.responses import StreamingResponse, FileResponse from pydantic import BaseModel @@ -7,11 +8,11 @@ from pydantic import BaseModel from fpdf import FPDF import markdown - +from apps.web.internal.db import DB from utils.utils import get_admin_user from utils.misc import calculate_sha256, get_gravatar_url -from config import OLLAMA_BASE_URLS, DATA_DIR, UPLOAD_DIR +from config import OLLAMA_BASE_URLS, DATA_DIR, UPLOAD_DIR, ENABLE_ADMIN_EXPORT from constants import ERROR_MESSAGES from typing import List @@ -91,9 +92,18 @@ async def download_chat_as_pdf( @router.get("/db/download") async def download_db(user=Depends(get_admin_user)): - + if not ENABLE_ADMIN_EXPORT: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + if not isinstance(DB, SqliteDatabase): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DB_NOT_SQLITE, + ) return FileResponse( - f"{DATA_DIR}/webui.db", + DB.database, media_type="application/octet-stream", filename="webui.db", ) diff --git a/backend/config.py b/backend/config.py index 39411d25..9208a845 100644 --- a/backend/config.py +++ b/backend/config.py @@ -18,6 +18,51 @@ from secrets import token_bytes from constants import ERROR_MESSAGES +#################################### +# LOGGING +#################################### + +log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] + +GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() +if GLOBAL_LOG_LEVEL in log_levels: + logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) +else: + GLOBAL_LOG_LEVEL = "INFO" + +log = logging.getLogger(__name__) +log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") + +log_sources = [ + "AUDIO", + "COMFYUI", + "CONFIG", + "DB", + "IMAGES", + "LITELLM", + "MAIN", + "MODELS", + "OLLAMA", + "OPENAI", + "RAG", + "WEBHOOK", +] + +SRC_LOG_LEVELS = {} + +for source in log_sources: + log_env_var = source + "_LOG_LEVEL" + SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper() + if SRC_LOG_LEVELS[source] not in log_levels: + SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL + log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}") + +log.setLevel(SRC_LOG_LEVELS["CONFIG"]) + +#################################### +# Load .env file +#################################### + try: from dotenv import load_dotenv, find_dotenv @@ -26,9 +71,10 @@ except ImportError: log.warning("dotenv not installed, skipping...") WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") -WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png" +if WEBUI_NAME != "Open WebUI": + WEBUI_NAME += " (Open WebUI)" -shutil.copyfile("../build/favicon.png", "./static/favicon.png") +WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png" #################################### # ENV (dev,test,prod) @@ -103,47 +149,30 @@ for version in soup.find_all("h2"): CHANGELOG = changelog_json +#################################### +# DATA/FRONTEND BUILD DIR +#################################### + +DATA_DIR = str(Path(os.getenv("DATA_DIR", "./data")).resolve()) +FRONTEND_BUILD_DIR = str(Path(os.getenv("FRONTEND_BUILD_DIR", "../build"))) + +try: + with open(f"{DATA_DIR}/config.json", "r") as f: + CONFIG_DATA = json.load(f) +except: + CONFIG_DATA = {} #################################### -# LOGGING +# Static DIR #################################### -log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] -GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() -if GLOBAL_LOG_LEVEL in log_levels: - logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) +STATIC_DIR = str(Path(os.getenv("STATIC_DIR", "./static")).resolve()) + +frontend_favicon = f"{FRONTEND_BUILD_DIR}/favicon.png" +if os.path.exists(frontend_favicon): + shutil.copyfile(frontend_favicon, f"{STATIC_DIR}/favicon.png") else: - GLOBAL_LOG_LEVEL = "INFO" - -log = logging.getLogger(__name__) -log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") - -log_sources = [ - "AUDIO", - "COMFYUI", - "CONFIG", - "DB", - "IMAGES", - "LITELLM", - "MAIN", - "MODELS", - "OLLAMA", - "OPENAI", - "RAG", - "WEBHOOK", -] - -SRC_LOG_LEVELS = {} - -for source in log_sources: - log_env_var = source + "_LOG_LEVEL" - SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper() - if SRC_LOG_LEVELS[source] not in log_levels: - SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL - log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}") - -log.setLevel(SRC_LOG_LEVELS["CONFIG"]) - + logging.warning(f"Frontend favicon not found at {frontend_favicon}") #################################### # CUSTOM_NAME @@ -165,7 +194,7 @@ if CUSTOM_NAME: r = requests.get(url, stream=True) if r.status_code == 200: - with open("./static/favicon.png", "wb") as f: + with open(f"{STATIC_DIR}/favicon.png", "wb") as f: r.raw.decode_content = True shutil.copyfileobj(r.raw, f) @@ -173,22 +202,7 @@ if CUSTOM_NAME: except Exception as e: log.exception(e) pass -else: - if WEBUI_NAME != "Open WebUI": - WEBUI_NAME += " (Open WebUI)" -#################################### -# DATA/FRONTEND BUILD DIR -#################################### - -DATA_DIR = str(Path(os.getenv("DATA_DIR", "./data")).resolve()) -FRONTEND_BUILD_DIR = str(Path(os.getenv("FRONTEND_BUILD_DIR", "../build"))) - -try: - with open(f"{DATA_DIR}/config.json", "r") as f: - CONFIG_DATA = json.load(f) -except: - CONFIG_DATA = {} #################################### # File Upload DIR @@ -210,7 +224,7 @@ Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) # Docs DIR #################################### -DOCS_DIR = f"{DATA_DIR}/docs" +DOCS_DIR = os.getenv("DOCS_DIR", f"{DATA_DIR}/docs") Path(DOCS_DIR).mkdir(parents=True, exist_ok=True) @@ -257,6 +271,7 @@ OLLAMA_API_BASE_URL = os.environ.get( OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "") K8S_FLAG = os.environ.get("K8S_FLAG", "") +USE_OLLAMA_DOCKER = os.environ.get("USE_OLLAMA_DOCKER", "false") if OLLAMA_BASE_URL == "" and OLLAMA_API_BASE_URL != "": OLLAMA_BASE_URL = ( @@ -266,9 +281,13 @@ if OLLAMA_BASE_URL == "" and OLLAMA_API_BASE_URL != "": ) if ENV == "prod": - if OLLAMA_BASE_URL == "/ollama": - OLLAMA_BASE_URL = "http://host.docker.internal:11434" - + if OLLAMA_BASE_URL == "/ollama" and not K8S_FLAG: + if USE_OLLAMA_DOCKER.lower() == "true": + # if you use all-in-one docker container (Open WebUI + Ollama) + # with the docker build arg USE_OLLAMA=true (--build-arg="USE_OLLAMA=true") this only works with http://localhost:11434 + OLLAMA_BASE_URL = "http://localhost:11434" + else: + OLLAMA_BASE_URL = "http://host.docker.internal:11434" elif K8S_FLAG: OLLAMA_BASE_URL = "http://ollama-service.open-webui.svc.cluster.local:11434" @@ -306,6 +325,18 @@ OPENAI_API_BASE_URLS = [ for url in OPENAI_API_BASE_URLS.split(";") ] +OPENAI_API_KEY = "" + +try: + OPENAI_API_KEY = OPENAI_API_KEYS[ + OPENAI_API_BASE_URLS.index("https://api.openai.com/v1") + ] +except: + pass + +OPENAI_API_BASE_URL = "https://api.openai.com/v1" + + #################################### # WEBUI #################################### @@ -336,6 +367,17 @@ DEFAULT_PROMPT_SUGGESTIONS = ( "title": ["Show me a code snippet", "of a website's sticky header"], "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.", }, + { + "title": [ + "Explain options trading", + "if I'm familiar with buying and selling stocks", + ], + "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks.", + }, + { + "title": ["Overcome procrastination", "give me tips"], + "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", + }, ] ) @@ -348,13 +390,14 @@ USER_PERMISSIONS_CHAT_DELETION = ( USER_PERMISSIONS = {"chat": {"deletion": USER_PERMISSIONS_CHAT_DELETION}} - -MODEL_FILTER_ENABLED = os.environ.get("MODEL_FILTER_ENABLED", "False").lower() == "true" +ENABLE_MODEL_FILTER = os.environ.get("ENABLE_MODEL_FILTER", "False").lower() == "true" MODEL_FILTER_LIST = os.environ.get("MODEL_FILTER_LIST", "") MODEL_FILTER_LIST = [model.strip() for model in MODEL_FILTER_LIST.split(";")] WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "") +ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true" + #################################### # WEBUI_VERSION #################################### @@ -389,21 +432,87 @@ if WEBUI_AUTH and WEBUI_SECRET_KEY == "": #################################### CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db" -# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (all-MiniLM-L6-v2) -RAG_EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", "all-MiniLM-L6-v2") -# device type ebbeding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance -RAG_EMBEDDING_MODEL_DEVICE_TYPE = os.environ.get( - "RAG_EMBEDDING_MODEL_DEVICE_TYPE", "cpu" -) -CHROMA_CLIENT = chromadb.PersistentClient( - path=CHROMA_DATA_PATH, - settings=Settings(allow_reset=True, anonymized_telemetry=False), -) -CHUNK_SIZE = 1500 -CHUNK_OVERLAP = 100 +CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT) +CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE) +CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "") +CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000")) +# Comma-separated list of header=value pairs +CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "") +if CHROMA_HTTP_HEADERS: + CHROMA_HTTP_HEADERS = dict( + [pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")] + ) +else: + CHROMA_HTTP_HEADERS = None +CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" +# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) +RAG_TOP_K = int(os.environ.get("RAG_TOP_K", "5")) +RAG_RELEVANCE_THRESHOLD = float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")) -RAG_TEMPLATE = """Use the following context as your learned knowledge, inside XML tags. +ENABLE_RAG_HYBRID_SEARCH = ( + os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true" +) + +RAG_EMBEDDING_ENGINE = os.environ.get("RAG_EMBEDDING_ENGINE", "") + +PDF_EXTRACT_IMAGES = os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true" + +RAG_EMBEDDING_MODEL = os.environ.get( + "RAG_EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2" +) +log.info(f"Embedding model set: {RAG_EMBEDDING_MODEL}"), + +RAG_EMBEDDING_MODEL_AUTO_UPDATE = ( + os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "").lower() == "true" +) + +RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE = ( + os.environ.get("RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE", "").lower() == "true" +) + +RAG_RERANKING_MODEL = os.environ.get("RAG_RERANKING_MODEL", "") +if not RAG_RERANKING_MODEL == "": + log.info(f"Reranking model set: {RAG_RERANKING_MODEL}"), + +RAG_RERANKING_MODEL_AUTO_UPDATE = ( + os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "").lower() == "true" +) + +RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = ( + os.environ.get("RAG_RERANKING_MODEL_TRUST_REMOTE_CODE", "").lower() == "true" +) + +# device type embedding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance +USE_CUDA = os.environ.get("USE_CUDA_DOCKER", "false") + +if USE_CUDA.lower() == "true": + DEVICE_TYPE = "cuda" +else: + DEVICE_TYPE = "cpu" + +if CHROMA_HTTP_HOST != "": + CHROMA_CLIENT = chromadb.HttpClient( + host=CHROMA_HTTP_HOST, + port=CHROMA_HTTP_PORT, + headers=CHROMA_HTTP_HEADERS, + ssl=CHROMA_HTTP_SSL, + tenant=CHROMA_TENANT, + database=CHROMA_DATABASE, + settings=Settings(allow_reset=True, anonymized_telemetry=False), + ) +else: + CHROMA_CLIENT = chromadb.PersistentClient( + path=CHROMA_DATA_PATH, + settings=Settings(allow_reset=True, anonymized_telemetry=False), + tenant=CHROMA_TENANT, + database=CHROMA_DATABASE, + ) + +CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "1500")) +CHUNK_OVERLAP = int(os.environ.get("CHUNK_OVERLAP", "100")) + +DEFAULT_RAG_TEMPLATE = """Use the following context as your learned knowledge, inside XML tags. [context] @@ -413,21 +522,74 @@ When answer to user: - If you don't know when you are not sure, ask for clarification. Avoid mentioning that you obtained the information from the context. And answer according to the language of the user's question. - + Given the context information, answer the query. Query: [query]""" +RAG_TEMPLATE = os.environ.get("RAG_TEMPLATE", DEFAULT_RAG_TEMPLATE) + +RAG_OPENAI_API_BASE_URL = os.getenv("RAG_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL) +RAG_OPENAI_API_KEY = os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY) + +ENABLE_LOCAL_WEB_FETCH = os.getenv("ENABLE_LOCAL_WEB_FETCH", "False").lower() == "true" + #################################### # Transcribe #################################### WHISPER_MODEL = os.getenv("WHISPER_MODEL", "base") WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models") +WHISPER_MODEL_AUTO_UPDATE = ( + os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true" +) #################################### # Images #################################### +IMAGE_GENERATION_ENGINE = os.getenv("IMAGE_GENERATION_ENGINE", "") + +ENABLE_IMAGE_GENERATION = ( + os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true" +) AUTOMATIC1111_BASE_URL = os.getenv("AUTOMATIC1111_BASE_URL", "") + COMFYUI_BASE_URL = os.getenv("COMFYUI_BASE_URL", "") + +IMAGES_OPENAI_API_BASE_URL = os.getenv( + "IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL +) +IMAGES_OPENAI_API_KEY = os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY) + +IMAGE_SIZE = os.getenv("IMAGE_SIZE", "512x512") + +IMAGE_STEPS = int(os.getenv("IMAGE_STEPS", 50)) + +IMAGE_GENERATION_MODEL = os.getenv("IMAGE_GENERATION_MODEL", "") + +#################################### +# Audio +#################################### + +AUDIO_OPENAI_API_BASE_URL = os.getenv("AUDIO_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL) +AUDIO_OPENAI_API_KEY = os.getenv("AUDIO_OPENAI_API_KEY", OPENAI_API_KEY) + +#################################### +# LiteLLM +#################################### + + +ENABLE_LITELLM = os.environ.get("ENABLE_LITELLM", "True").lower() == "true" + +LITELLM_PROXY_PORT = int(os.getenv("LITELLM_PROXY_PORT", "14365")) +if LITELLM_PROXY_PORT < 0 or LITELLM_PROXY_PORT > 65535: + raise ValueError("Invalid port number for LITELLM_PROXY_PORT") +LITELLM_PROXY_HOST = os.getenv("LITELLM_PROXY_HOST", "127.0.0.1") + + +#################################### +# Database +#################################### + +DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db") diff --git a/backend/constants.py b/backend/constants.py index da1ee0b3..3fdf506f 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -3,6 +3,10 @@ from enum import Enum class MESSAGES(str, Enum): DEFAULT = lambda msg="": f"{msg if msg else ''}" + MODEL_ADDED = lambda model="": f"The model '{model}' has been added successfully." + MODEL_DELETED = ( + lambda model="": f"The model '{model}' has been deleted successfully." + ) class WEBHOOK_MESSAGES(str, Enum): @@ -65,3 +69,9 @@ class ERROR_MESSAGES(str, Enum): CREATE_API_KEY_ERROR = "Oops! Something went wrong while creating your API key. Please try again later. If the issue persists, contact support for assistance." EMPTY_CONTENT = "The content provided is empty. Please ensure that there is text or data present before proceeding." + + DB_NOT_SQLITE = "This feature is only available when running with SQLite databases." + + INVALID_URL = ( + "Oops! The URL you provided is invalid. Please double-check and try again." + ) diff --git a/backend/data/config.json b/backend/data/config.json index 604ffb03..6c0ad2b9 100644 --- a/backend/data/config.json +++ b/backend/data/config.json @@ -18,6 +18,18 @@ { "title": ["Show me a code snippet", "of a website's sticky header"], "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript." + }, + { + "title": ["Explain options trading", "if I'm familiar with buying and selling stocks"], + "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks." + }, + { + "title": ["Overcome procrastination", "give me tips"], + "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?" + }, + { + "title": ["Grammar check", "rewrite it for better readability "], + "content": "Check the following sentence for grammar and clarity: \"[sentence]\". Rewrite it for better readability while maintaining its original meaning." } ] } diff --git a/backend/dev.sh b/backend/dev.sh old mode 100644 new mode 100755 diff --git a/backend/main.py b/backend/main.py index f574e7ba..e37ac324 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,6 +5,7 @@ import time import os import sys import logging +import aiohttp import requests from fastapi import FastAPI, Request, Depends, status @@ -18,12 +19,18 @@ from starlette.middleware.base import BaseHTTPMiddleware from apps.ollama.main import app as ollama_app from apps.openai.main import app as openai_app -from apps.litellm.main import app as litellm_app, startup as litellm_app_startup + +from apps.litellm.main import ( + app as litellm_app, + start_litellm_background, + shutdown_litellm_background, +) from apps.audio.main import app as audio_app from apps.images.main import app as images_app from apps.rag.main import app as rag_app from apps.web.main import app as webui_app +import asyncio from pydantic import BaseModel from typing import List @@ -38,11 +45,15 @@ from config import ( VERSION, CHANGELOG, FRONTEND_BUILD_DIR, - MODEL_FILTER_ENABLED, + CACHE_DIR, + STATIC_DIR, + ENABLE_LITELLM, + ENABLE_MODEL_FILTER, MODEL_FILTER_LIST, GLOBAL_LOG_LEVEL, SRC_LOG_LEVELS, WEBHOOK_URL, + ENABLE_ADMIN_EXPORT, ) from constants import ERROR_MESSAGES @@ -79,7 +90,7 @@ https://github.com/open-webui/open-webui app = FastAPI(docs_url="/docs" if ENV == "dev" else None, redoc_url=None) -app.state.MODEL_FILTER_ENABLED = MODEL_FILTER_ENABLED +app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST app.state.WEBHOOK_URL = WEBHOOK_URL @@ -106,11 +117,14 @@ class RAGMiddleware(BaseHTTPMiddleware): if "docs" in data: data = {**data} data["messages"] = rag_messages( - data["docs"], - data["messages"], - rag_app.state.RAG_TEMPLATE, - rag_app.state.TOP_K, - rag_app.state.sentence_transformer_ef, + docs=data["docs"], + messages=data["messages"], + template=rag_app.state.RAG_TEMPLATE, + embedding_function=rag_app.state.EMBEDDING_FUNCTION, + k=rag_app.state.TOP_K, + reranking_function=rag_app.state.sentence_transformer_rf, + r=rag_app.state.RELEVANCE_THRESHOLD, + hybrid_search=rag_app.state.ENABLE_RAG_HYBRID_SEARCH, ) del data["docs"] @@ -162,7 +176,8 @@ async def check_url(request: Request, call_next): @app.on_event("startup") async def on_startup(): - await litellm_app_startup() + if ENABLE_LITELLM: + asyncio.create_task(start_litellm_background()) app.mount("/api/v1", webui_app) @@ -194,13 +209,14 @@ async def get_app_config(): "default_models": webui_app.state.DEFAULT_MODELS, "default_prompt_suggestions": webui_app.state.DEFAULT_PROMPT_SUGGESTIONS, "trusted_header_auth": bool(webui_app.state.AUTH_TRUSTED_EMAIL_HEADER), + "admin_export_enabled": ENABLE_ADMIN_EXPORT, } @app.get("/api/config/model/filter") async def get_model_filter_config(user=Depends(get_admin_user)): return { - "enabled": app.state.MODEL_FILTER_ENABLED, + "enabled": app.state.ENABLE_MODEL_FILTER, "models": app.state.MODEL_FILTER_LIST, } @@ -214,20 +230,20 @@ class ModelFilterConfigForm(BaseModel): async def update_model_filter_config( form_data: ModelFilterConfigForm, user=Depends(get_admin_user) ): - app.state.MODEL_FILTER_ENABLED = form_data.enabled + app.state.ENABLE_MODEL_FILTER = form_data.enabled app.state.MODEL_FILTER_LIST = form_data.models - ollama_app.state.MODEL_FILTER_ENABLED = app.state.MODEL_FILTER_ENABLED + ollama_app.state.ENABLE_MODEL_FILTER = app.state.ENABLE_MODEL_FILTER ollama_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST - openai_app.state.MODEL_FILTER_ENABLED = app.state.MODEL_FILTER_ENABLED + openai_app.state.ENABLE_MODEL_FILTER = app.state.ENABLE_MODEL_FILTER openai_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST - litellm_app.state.MODEL_FILTER_ENABLED = app.state.MODEL_FILTER_ENABLED + litellm_app.state.ENABLE_MODEL_FILTER = app.state.ENABLE_MODEL_FILTER litellm_app.state.MODEL_FILTER_LIST = app.state.MODEL_FILTER_LIST return { - "enabled": app.state.MODEL_FILTER_ENABLED, + "enabled": app.state.ENABLE_MODEL_FILTER, "models": app.state.MODEL_FILTER_LIST, } @@ -269,14 +285,16 @@ async def get_app_changelog(): @app.get("/api/version/updates") async def get_app_latest_release_version(): try: - response = requests.get( - f"https://api.github.com/repos/open-webui/open-webui/releases/latest" - ) - response.raise_for_status() - latest_version = response.json()["tag_name"] + async with aiohttp.ClientSession() as session: + async with session.get( + "https://api.github.com/repos/open-webui/open-webui/releases/latest" + ) as response: + response.raise_for_status() + data = await response.json() + latest_version = data["tag_name"] - return {"current": VERSION, "latest": latest_version[1:]} - except Exception as e: + return {"current": VERSION, "latest": latest_version[1:]} + except aiohttp.ClientError as e: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED, @@ -293,16 +311,26 @@ async def get_manifest_json(): "background_color": "#343541", "theme_color": "#343541", "orientation": "portrait-primary", - "icons": [{"src": "/favicon.png", "type": "image/png", "sizes": "844x884"}], + "icons": [{"src": "/static/logo.png", "type": "image/png", "sizes": "500x500"}], } -app.mount("/static", StaticFiles(directory="static"), name="static") -app.mount("/cache", StaticFiles(directory="data/cache"), name="cache") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache") + +if os.path.exists(FRONTEND_BUILD_DIR): + app.mount( + "/", + SPAStaticFiles(directory=FRONTEND_BUILD_DIR, html=True), + name="spa-static-files", + ) +else: + log.warning( + f"Frontend build directory not found at '{FRONTEND_BUILD_DIR}'. Serving API only." + ) -app.mount( - "/", - SPAStaticFiles(directory=FRONTEND_BUILD_DIR, html=True), - name="spa-static-files", -) +@app.on_event("shutdown") +async def shutdown_event(): + if ENABLE_LITELLM: + await shutdown_litellm_background() diff --git a/backend/requirements.txt b/backend/requirements.txt index c815d93d..ce01cf50 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,53 +1,62 @@ -fastapi -uvicorn[standard] -pydantic -python-multipart +fastapi==0.109.2 +uvicorn[standard]==0.22.0 +pydantic==2.7.1 +python-multipart==0.0.9 -flask -flask_cors +Flask==3.0.3 +Flask-Cors==4.0.0 -python-socketio -python-jose -passlib[bcrypt] -uuid +python-socketio==5.11.2 +python-jose==3.3.0 +passlib[bcrypt]==1.7.4 +uuid==1.30 -requests -aiohttp -peewee -peewee-migrate -bcrypt +requests==2.31.0 +aiohttp==3.9.5 +peewee==3.17.3 +peewee-migrate==1.12.2 +psycopg2-binary==2.9.9 +PyMySQL==1.1.0 +bcrypt==4.1.2 -litellm==1.30.7 -boto3 +litellm==1.35.28 +litellm[proxy]==1.35.28 -argon2-cffi -apscheduler -google-generativeai +boto3==1.34.95 -langchain -langchain-community -fake_useragent -chromadb -sentence_transformers -pypdf -docx2txt -unstructured -markdown -pypandoc -pandas -openpyxl -pyxlsb -xlrd +argon2-cffi==23.1.0 +APScheduler==3.10.4 +google-generativeai==0.5.2 -opencv-python-headless -rapidocr-onnxruntime +langchain==0.1.16 +langchain-community==0.0.34 +langchain-chroma==0.1.0 -fpdf2 +fake-useragent==1.5.1 +chromadb==0.4.24 +sentence-transformers==2.7.0 +pypdf==4.2.0 +docx2txt==0.8 +unstructured==0.11.8 +Markdown==3.6 +pypandoc==1.13 +pandas==2.2.2 +openpyxl==3.1.2 +pyxlsb==1.0.10 +xlrd==2.0.1 +validators==0.28.1 -faster-whisper +opencv-python-headless==4.9.0.80 +rapidocr-onnxruntime==1.2.3 -PyJWT -pyjwt[crypto] +fpdf2==2.7.8 +rank-bm25==0.2.2 -black -langfuse +faster-whisper==1.0.1 + +PyJWT==2.8.0 +PyJWT[crypto]==2.8.0 + +black==24.4.2 +langfuse==2.27.3 +youtube-transcript-api diff --git a/backend/start.sh b/backend/start.sh index f9ed5948..9a8e9113 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -6,17 +6,28 @@ cd "$SCRIPT_DIR" || exit KEY_FILE=.webui_secret_key PORT="${PORT:-8080}" +HOST="${HOST:-0.0.0.0}" if test "$WEBUI_SECRET_KEY $WEBUI_JWT_SECRET_KEY" = " "; then - echo No WEBUI_SECRET_KEY provided + echo "No WEBUI_SECRET_KEY provided" if ! [ -e "$KEY_FILE" ]; then - echo Generating WEBUI_SECRET_KEY + echo "Generating WEBUI_SECRET_KEY" # Generate a random value to use as a WEBUI_SECRET_KEY in case the user didn't provide one. - echo $(head -c 12 /dev/random | base64) > $KEY_FILE + echo $(head -c 12 /dev/random | base64) > "$KEY_FILE" fi - echo Loading WEBUI_SECRET_KEY from $KEY_FILE - WEBUI_SECRET_KEY=`cat $KEY_FILE` + echo "Loading WEBUI_SECRET_KEY from $KEY_FILE" + WEBUI_SECRET_KEY=$(cat "$KEY_FILE") fi -WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec uvicorn main:app --host 0.0.0.0 --port "$PORT" --forwarded-allow-ips '*' \ No newline at end of file +if [ "$USE_OLLAMA_DOCKER" = "true" ]; then + echo "USE_OLLAMA is set to true, starting ollama serve." + ollama serve & +fi + +if [ "$USE_CUDA_DOCKER" = "true" ]; then + echo "CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries." + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/local/lib/python3.11/site-packages/torch/lib:/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib" +fi + +WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec uvicorn main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' diff --git a/backend/start_windows.bat b/backend/start_windows.bat index b2c37017..e687de10 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -7,7 +7,7 @@ SET "SCRIPT_DIR=%~dp0" cd /d "%SCRIPT_DIR%" || exit /b SET "KEY_FILE=.webui_secret_key" -SET "PORT=%PORT:8080%" +IF "%PORT%"=="" SET PORT=8080 SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%" diff --git a/backend/static/favicon.png b/backend/static/favicon.png index 519af1db..2b207478 100644 Binary files a/backend/static/favicon.png and b/backend/static/favicon.png differ diff --git a/backend/static/logo.png b/backend/static/logo.png new file mode 100644 index 00000000..519af1db Binary files /dev/null and b/backend/static/logo.png differ diff --git a/backend/static/user-import.csv b/backend/static/user-import.csv new file mode 100644 index 00000000..918a92aa --- /dev/null +++ b/backend/static/user-import.csv @@ -0,0 +1 @@ +Name,Email,Password,Role diff --git a/backend/utils/logo.png b/backend/utils/logo.png new file mode 100644 index 00000000..519af1db Binary files /dev/null and b/backend/utils/logo.png differ diff --git a/backend/utils/utils.py b/backend/utils/utils.py index 49e15789..af4fd85c 100644 --- a/backend/utils/utils.py +++ b/backend/utils/utils.py @@ -89,6 +89,8 @@ def get_current_user( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.INVALID_TOKEN, ) + else: + Users.update_user_last_active_by_id(user.id) return user else: raise HTTPException( @@ -99,11 +101,15 @@ def get_current_user( def get_current_user_by_api_key(api_key: str): user = Users.get_user_by_api_key(api_key) + if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.INVALID_TOKEN, ) + else: + Users.update_user_last_active_by_id(user.id) + return user diff --git a/confirm_remove.sh b/confirm_remove.sh index 729c2507..051908e6 100755 --- a/confirm_remove.sh +++ b/confirm_remove.sh @@ -2,7 +2,12 @@ echo "Warning: This will remove all containers and volumes, including persistent data. Do you want to continue? [Y/N]" read ans if [ "$ans" == "Y" ] || [ "$ans" == "y" ]; then - docker-compose down -v + command docker-compose 2>/dev/null + if [ "$?" == "0" ]; then + docker-compose down -v + else + docker compose down -v + fi else echo "Operation cancelled." fi diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 00000000..dbb53823 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:8080' + }, + video: true +}); diff --git a/cypress/e2e/chat.cy.ts b/cypress/e2e/chat.cy.ts new file mode 100644 index 00000000..f46bef57 --- /dev/null +++ b/cypress/e2e/chat.cy.ts @@ -0,0 +1,46 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +// These tests run through the chat flow. +describe('Settings', () => { + // Wait for 2 seconds after all tests to fix an issue with Cypress's video recording missing the last few frames + after(() => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + }); + + beforeEach(() => { + // Login as the admin user + cy.loginAdmin(); + // Visit the home page + cy.visit('/'); + }); + + context('Ollama', () => { + it('user can select a model', () => { + // Click on the model selector + cy.get('button[aria-label="Select a model"]').click(); + // Select the first model + cy.get('button[aria-label="model-item"]').first().click(); + }); + + it('user can perform text chat', () => { + // Click on the model selector + cy.get('button[aria-label="Select a model"]').click(); + // Select the first model + cy.get('button[aria-label="model-item"]').first().click(); + // Type a message + cy.get('#chat-textarea').type('Hi, what can you do? A single sentence only please.', { + force: true + }); + // Send the message + cy.get('button[type="submit"]').click(); + // User's message should be visible + cy.get('.chat-user').should('exist'); + // Wait for the response + cy.get('.chat-assistant', { timeout: 120_000 }) // .chat-assistant is created after the first token is received + .find('div[aria-label="Generation Info"]', { timeout: 120_000 }) // Generation Info is created after the stop token is received + .should('exist'); + }); + }); +}); diff --git a/cypress/e2e/registration.cy.ts b/cypress/e2e/registration.cy.ts new file mode 100644 index 00000000..232d75e8 --- /dev/null +++ b/cypress/e2e/registration.cy.ts @@ -0,0 +1,52 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +import { adminUser } from '../support/e2e'; + +// These tests assume the following defaults: +// 1. No users exist in the database or that the test admin user is an admin +// 2. Language is set to English +// 3. The default role for new users is 'pending' +describe('Registration and Login', () => { + // Wait for 2 seconds after all tests to fix an issue with Cypress's video recording missing the last few frames + after(() => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + }); + + beforeEach(() => { + cy.visit('/'); + }); + + it('should register a new user as pending', () => { + const userName = `Test User - ${Date.now()}`; + const userEmail = `cypress-${Date.now()}@example.com`; + // Toggle from sign in to sign up + cy.contains('Sign up').click(); + // Fill out the form + cy.get('input[autocomplete="name"]').type(userName); + cy.get('input[autocomplete="email"]').type(userEmail); + cy.get('input[type="password"]').type('password'); + // Submit the form + cy.get('button[type="submit"]').click(); + // Wait until the user is redirected to the home page + cy.contains(userName); + // Expect the user to be pending + cy.contains('Check Again'); + }); + + it('can login with the admin user', () => { + // Fill out the form + cy.get('input[autocomplete="email"]').type(adminUser.email); + cy.get('input[type="password"]').type(adminUser.password); + // Submit the form + cy.get('button[type="submit"]').click(); + // Wait until the user is redirected to the home page + cy.contains(adminUser.name); + // Dismiss the changelog dialog if it is visible + cy.getAllLocalStorage().then((ls) => { + if (!ls['version']) { + cy.get('button').contains("Okay, Let's Go!").click(); + } + }); + }); +}); diff --git a/cypress/e2e/settings.cy.ts b/cypress/e2e/settings.cy.ts new file mode 100644 index 00000000..560ce979 --- /dev/null +++ b/cypress/e2e/settings.cy.ts @@ -0,0 +1,88 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +import { adminUser } from '../support/e2e'; + +// These tests run through the various settings pages, ensuring that the user can interact with them as expected +describe('Settings', () => { + // Wait for 2 seconds after all tests to fix an issue with Cypress's video recording missing the last few frames + after(() => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + }); + + beforeEach(() => { + // Login as the admin user + cy.loginAdmin(); + // Visit the home page + cy.visit('/'); + // Open the sidebar if it is not already open + cy.get('[aria-label="Open sidebar"]').then(() => { + cy.get('button[id="sidebar-toggle-button"]').click(); + }); + // Click on the profile link + cy.get('button').contains(adminUser.name).click(); + // Click on the settings link + cy.get('button').contains('Settings').click(); + }); + + context('General', () => { + it('user can open the General modal and hit save', () => { + cy.get('button').contains('General').click(); + cy.get('button').contains('Save').click(); + }); + }); + + context('Connections', () => { + it('user can open the Connections modal and hit save', () => { + cy.get('button').contains('Connections').click(); + cy.get('button').contains('Save').click(); + }); + }); + + context('Models', () => { + it('user can open the Models modal', () => { + cy.get('button').contains('Models').click(); + }); + }); + + context('Interface', () => { + it('user can open the Interface modal and hit save', () => { + cy.get('button').contains('Interface').click(); + cy.get('button').contains('Save').click(); + }); + }); + + context('Audio', () => { + it('user can open the Audio modal and hit save', () => { + cy.get('button').contains('Audio').click(); + cy.get('button').contains('Save').click(); + }); + }); + + context('Images', () => { + it('user can open the Images modal and hit save', () => { + cy.get('button').contains('Images').click(); + // Currently fails because the backend requires a valid URL + // cy.get('button').contains('Save').click(); + }); + }); + + context('Chats', () => { + it('user can open the Chats modal', () => { + cy.get('button').contains('Chats').click(); + }); + }); + + context('Account', () => { + it('user can open the Account modal and hit save', () => { + cy.get('button').contains('Account').click(); + cy.get('button').contains('Save').click(); + }); + }); + + context('About', () => { + it('user can open the About modal', () => { + cy.get('button').contains('About').click(); + }); + }); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 00000000..1eedc98d --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,73 @@ +/// + +export const adminUser = { + name: 'Admin User', + email: 'admin@example.com', + password: 'password' +}; + +const login = (email: string, password: string) => { + return cy.session( + email, + () => { + // Visit auth page + cy.visit('/auth'); + // Fill out the form + cy.get('input[autocomplete="email"]').type(email); + cy.get('input[type="password"]').type(password); + // Submit the form + cy.get('button[type="submit"]').click(); + // Wait until the user is redirected to the home page + cy.get('#chat-search').should('exist'); + // Get the current version to skip the changelog dialog + if (localStorage.getItem('version') === null) { + cy.get('button').contains("Okay, Let's Go!").click(); + } + }, + { + validate: () => { + cy.request({ + method: 'GET', + url: '/api/v1/auths/', + headers: { + Authorization: 'Bearer ' + localStorage.getItem('token') + } + }); + } + } + ); +}; + +const register = (name: string, email: string, password: string) => { + return cy + .request({ + method: 'POST', + url: '/api/v1/auths/signup', + body: { + name: name, + email: email, + password: password + }, + failOnStatusCode: false + }) + .then((response) => { + expect(response.status).to.be.oneOf([200, 400]); + }); +}; + +const registerAdmin = () => { + return register(adminUser.name, adminUser.email, adminUser.password); +}; + +const loginAdmin = () => { + return login(adminUser.email, adminUser.password); +}; + +Cypress.Commands.add('login', (email, password) => login(email, password)); +Cypress.Commands.add('register', (name, email, password) => register(name, email, password)); +Cypress.Commands.add('registerAdmin', () => registerAdmin()); +Cypress.Commands.add('loginAdmin', () => loginAdmin()); + +before(() => { + cy.registerAdmin(); +}); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts new file mode 100644 index 00000000..e6c69121 --- /dev/null +++ b/cypress/support/index.d.ts @@ -0,0 +1,11 @@ +// load the global Cypress types +/// + +declare namespace Cypress { + interface Chainable { + login(email: string, password: string): Chainable; + register(name: string, email: string, password: string): Chainable; + registerAdmin(): Chainable; + loginAdmin(): Chainable; + } +} diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000..ff28d946 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "inlineSourceMap": true, + "sourceMap": false + } +} diff --git a/docker-compose.amdgpu.yaml b/docker-compose.amdgpu.yaml new file mode 100644 index 00000000..7a1295d9 --- /dev/null +++ b/docker-compose.amdgpu.yaml @@ -0,0 +1,8 @@ +services: + ollama: + devices: + - /dev/kfd:/dev/kfd + - /dev/dri:/dev/dri + image: ollama/ollama:${OLLAMA_DOCKER_TAG-rocm} + environment: + - 'HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION-11.0.0}' \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index f69084b8..9daba312 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: pull_policy: always tty: true restart: unless-stopped - image: ollama/ollama:latest + image: ollama/ollama:${OLLAMA_DOCKER_TAG-latest} open-webui: build: @@ -16,7 +16,7 @@ services: args: OLLAMA_BASE_URL: '/ollama' dockerfile: Dockerfile - image: ghcr.io/open-webui/open-webui:main + image: ghcr.io/open-webui/open-webui:${WEBUI_DOCKER_TAG-main} container_name: open-webui volumes: - open-webui:/app/backend/data diff --git a/kubernetes/helm/templates/_helpers.tpl b/kubernetes/helm/templates/_helpers.tpl index 3f42735a..6233efab 100644 --- a/kubernetes/helm/templates/_helpers.tpl +++ b/kubernetes/helm/templates/_helpers.tpl @@ -7,7 +7,11 @@ ollama {{- end -}} {{- define "ollama.url" -}} -{{- printf "http://%s.%s.svc.cluster.local:%d/" (include "ollama.name" .) (.Release.Namespace) (.Values.ollama.service.port | int) }} +{{- if .Values.ollama.externalHost }} +{{- printf .Values.ollama.externalHost }} +{{- else }} +{{- printf "http://%s.%s.svc.cluster.local:%d" (include "ollama.name" .) (.Release.Namespace) (.Values.ollama.service.port | int) }} +{{- end }} {{- end }} {{- define "chart.name" -}} diff --git a/kubernetes/helm/templates/ollama-service.yaml b/kubernetes/helm/templates/ollama-service.yaml index becb6ad2..32c93cae 100644 --- a/kubernetes/helm/templates/ollama-service.yaml +++ b/kubernetes/helm/templates/ollama-service.yaml @@ -1,3 +1,4 @@ +{{- if not .Values.ollama.externalHost }} apiVersion: v1 kind: Service metadata: @@ -19,3 +20,4 @@ spec: port: {{ .port }} targetPort: http {{- end }} +{{- end }} diff --git a/kubernetes/helm/templates/ollama-statefulset.yaml b/kubernetes/helm/templates/ollama-statefulset.yaml index c348b04c..2750956a 100644 --- a/kubernetes/helm/templates/ollama-statefulset.yaml +++ b/kubernetes/helm/templates/ollama-statefulset.yaml @@ -1,3 +1,4 @@ +{{- if not .Values.ollama.externalHost }} apiVersion: apps/v1 kind: StatefulSet metadata: @@ -94,3 +95,4 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} {{- end }} +{{- end }} diff --git a/kubernetes/helm/templates/webui-pvc.yaml b/kubernetes/helm/templates/webui-pvc.yaml index f06454e7..87833245 100644 --- a/kubernetes/helm/templates/webui-pvc.yaml +++ b/kubernetes/helm/templates/webui-pvc.yaml @@ -17,7 +17,9 @@ spec: resources: requests: storage: {{ .Values.webui.persistence.size }} + {{- if .Values.webui.persistence.storageClass }} storageClassName: {{ .Values.webui.persistence.storageClass }} + {{- end }} {{- with .Values.webui.persistence.selector }} selector: {{- toYaml . | nindent 4 }} diff --git a/kubernetes/helm/values.yaml b/kubernetes/helm/values.yaml index 394e5a49..4437973e 100644 --- a/kubernetes/helm/values.yaml +++ b/kubernetes/helm/values.yaml @@ -1,6 +1,7 @@ nameOverride: "" ollama: + externalHost: "" annotations: {} podAnnotations: {} replicaCount: 1 diff --git a/package-lock.json b/package-lock.json index cf149685..d3a8b513 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "open-webui", - "version": "0.1.117", + "version": "0.1.123", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.1.117", + "version": "0.1.123", "dependencies": { "@sveltejs/adapter-node": "^1.3.1", "async": "^3.2.5", "bits-ui": "^0.19.7", "dayjs": "^1.11.10", + "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", "highlight.js": "^11.9.0", "i18next": "^23.10.0", @@ -19,7 +20,6 @@ "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", - "jspdf": "^2.5.1", "katex": "^0.16.9", "marked": "^9.1.0", "svelte-sonner": "^0.3.19", @@ -35,8 +35,10 @@ "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "autoprefixer": "^10.4.16", + "cypress": "^13.8.1", "eslint": "^8.56.0", "eslint-config-prettier": "^8.5.0", + "eslint-plugin-cypress": "^3.0.2", "eslint-plugin-svelte": "^2.30.0", "i18next-parser": "^8.13.0", "postcss": "^8.4.31", @@ -95,6 +97,73 @@ "node": ">=6.9.0" } }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cypress/request": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", + "dev": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "http-signature": "~1.3.6", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "6.10.4", + "safe-buffer": "^5.1.2", + "tough-cookie": "^4.1.3", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/request/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cypress/xvfb": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", + "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "dev": true, + "dependencies": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + } + }, + "node_modules/@cypress/xvfb/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -1068,12 +1137,6 @@ "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "dev": true }, - "node_modules/@types/raf": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", - "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", - "optional": true - }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -1085,6 +1148,18 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", + "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", + "dev": true + }, + "node_modules/@types/sizzle": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", + "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", + "dev": true + }, "node_modules/@types/symlink-or-copy": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz", @@ -1100,6 +1175,16 @@ "@types/node": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -1316,6 +1401,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1332,6 +1430,42 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1375,6 +1509,26 @@ "node": ">= 8" } }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1404,20 +1558,51 @@ "node": ">=8" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "bin": { - "atob": "bin/atob.js" - }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, "engines": { - "node": ">= 4.5.0" + "node": ">= 4.0.0" } }, "node_modules/autoprefixer": { @@ -1457,6 +1642,21 @@ "postcss": "^8.1.0" } }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "dev": true + }, "node_modules/axobject-query": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", @@ -1477,15 +1677,6 @@ "dev": true, "optional": true }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "optional": true, - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1506,6 +1697,15 @@ } ] }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1556,6 +1756,18 @@ "node": ">= 6" } }, + "node_modules/blob-util": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", + "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", + "dev": true + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1693,17 +1905,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/btoa": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", - "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", - "bin": { - "btoa": "bin/btoa.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -1758,6 +1959,34 @@ "@types/ws": "~8.5.10" } }, + "node_modules/cachedir": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1796,30 +2025,11 @@ } ] }, - "node_modules/canvg": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz", - "integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==", - "optional": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "@types/raf": "^3.4.0", - "core-js": "^3.8.3", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.7", - "rgbcolor": "^1.0.1", - "stackblur-canvas": "^2.0.0", - "svg-pathdata": "^6.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/canvg/node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "optional": true + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true }, "node_modules/chalk": { "version": "4.1.2", @@ -1837,6 +2047,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", @@ -1911,6 +2130,113 @@ "node": ">= 6" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.4.tgz", + "integrity": "sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -1964,6 +2290,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, "node_modules/colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -1973,6 +2305,18 @@ "node": ">=0.1.90" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -1982,6 +2326,15 @@ "node": ">=16" } }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2007,17 +2360,6 @@ "node": ">= 0.6" } }, - "node_modules/core-js": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.1.tgz", - "integrity": "sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA==", - "hasInstallScript": true, - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2038,15 +2380,6 @@ "node": ">= 8" } }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -2099,6 +2432,138 @@ "node": ">=4" } }, + "node_modules/cypress": { + "version": "13.8.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.8.1.tgz", + "integrity": "sha512-Uk6ovhRbTg6FmXjeZW/TkbRM07KPtvM5gah1BIMp4Y2s+i/NMxgaLw0+PbYTOdw1+egE0FP3mWRiGcRkjjmhzA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@cypress/request": "^3.0.0", + "@cypress/xvfb": "^1.2.4", + "@types/sinonjs__fake-timers": "8.1.1", + "@types/sizzle": "^2.3.2", + "arch": "^2.2.0", + "blob-util": "^2.0.2", + "bluebird": "^3.7.2", + "buffer": "^5.7.1", + "cachedir": "^2.3.0", + "chalk": "^4.1.0", + "check-more-types": "^2.24.0", + "cli-cursor": "^3.1.0", + "cli-table3": "~0.6.1", + "commander": "^6.2.1", + "common-tags": "^1.8.0", + "dayjs": "^1.10.4", + "debug": "^4.3.4", + "enquirer": "^2.3.6", + "eventemitter2": "6.4.7", + "execa": "4.1.0", + "executable": "^4.1.1", + "extract-zip": "2.0.1", + "figures": "^3.2.0", + "fs-extra": "^9.1.0", + "getos": "^3.2.1", + "is-ci": "^3.0.1", + "is-installed-globally": "~0.4.0", + "lazy-ass": "^1.6.0", + "listr2": "^3.8.3", + "lodash": "^4.17.21", + "log-symbols": "^4.0.0", + "minimist": "^1.2.8", + "ospath": "^1.2.2", + "pretty-bytes": "^5.6.0", + "process": "^0.11.10", + "proxy-from-env": "1.0.0", + "request-progress": "^3.0.0", + "semver": "^7.5.3", + "supports-color": "^8.1.1", + "tmp": "~0.2.1", + "untildify": "^4.0.0", + "yauzl": "^2.10.0" + }, + "bin": { + "cypress": "bin/cypress" + }, + "engines": { + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" + } + }, + "node_modules/cypress/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cypress/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cypress/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cypress/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dayjs": { "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", @@ -2140,6 +2605,32 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2239,12 +2730,6 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/dompurify": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.9.tgz", - "integrity": "sha512-iHtnxYMotKgOTvxIqq677JsKHvCOkAFqj9x8Mek2zdeHW1XjuFKwjpmZeMaXQRQ8AbJZDbcRz/+r1QhwvFtmQg==", - "optional": true - }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", @@ -2265,6 +2750,16 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.715", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.715.tgz", @@ -2277,6 +2772,28 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/ensure-posix-path": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ensure-posix-path/-/ensure-posix-path-1.1.1.tgz", @@ -2301,6 +2818,27 @@ "integrity": "sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==", "dev": true }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -2445,6 +2983,18 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-cypress": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-3.0.2.tgz", + "integrity": "sha512-5hIWc3SqXSuR+Sd7gmNMzx8yJ3LWQQS0e+qLvEVF4C1JfFtu1s9imtEm1KxlCBCcKb7+6CyR9KQYs0GiI02AlA==", + "dev": true, + "dependencies": { + "globals": "^13.20.0" + }, + "peerDependencies": { + "eslint": ">=7 <9" + } + }, "node_modules/eslint-plugin-svelte": { "version": "2.35.1", "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.35.1.tgz", @@ -2612,6 +3162,96 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter2": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", + "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", + "dev": true + }, + "node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "dev": true, + "dependencies": { + "pify": "^2.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ] + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2673,10 +3313,38 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } }, "node_modules/file-entry-cache": { "version": "6.0.1", @@ -2767,6 +3435,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -2894,6 +3585,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/getos": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", + "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", + "dev": true, + "dependencies": { + "async": "^3.2.0" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -2954,6 +3697,21 @@ "node": ">=10" } }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -2999,6 +3757,18 @@ "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3029,6 +3799,42 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3097,19 +3903,6 @@ "node": ">=12.0.0" } }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "optional": true, - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -3129,6 +3922,29 @@ "entities": "^4.4.0" } }, + "node_modules/http-signature": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", + "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==", + "dev": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^2.0.2", + "sshpk": "^1.14.1" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "engines": { + "node": ">=8.12.0" + } + }, "node_modules/i18next": { "version": "23.10.1", "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.1.tgz", @@ -3282,6 +4098,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3296,6 +4121,15 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3322,6 +4156,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -3363,6 +4209,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dev": true, + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -3415,6 +4277,36 @@ "@types/estree": "*" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-valid-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", @@ -3436,6 +4328,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true + }, "node_modules/jackspeak": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", @@ -3480,12 +4378,24 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3498,6 +4408,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -3510,21 +4426,19 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jspdf": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.1.tgz", - "integrity": "sha512-hXObxz7ZqoyhxET78+XR34Xu2qFGrJJ2I2bE5w4SM8eFaFEkW2xcGRVUss360fYelwRSid/jT078kbNvmoW0QA==", + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], "dependencies": { - "@babel/runtime": "^7.14.0", - "atob": "^2.1.2", - "btoa": "^1.2.1", - "fflate": "^0.4.8" - }, - "optionalDependencies": { - "canvg": "^3.0.6", - "core-js": "^3.6.0", - "dompurify": "^2.2.0", - "html2canvas": "^1.0.0-rc.5" + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" } }, "node_modules/katex": { @@ -3573,6 +4487,15 @@ "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", "dev": true }, + "node_modules/lazy-ass": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", + "dev": true, + "engines": { + "node": "> 0.8" + } + }, "node_modules/lead": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/lead/-/lead-4.0.0.tgz", @@ -3613,6 +4536,70 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/listr2": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", + "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", + "dev": true, + "dependencies": { + "cli-truncate": "^2.1.0", + "colorette": "^2.0.16", + "log-update": "^4.0.0", + "p-map": "^4.0.0", + "rfdc": "^1.3.0", + "rxjs": "^7.5.1", + "through": "^2.3.8", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "enquirer": ">= 2.3.0 < 3" + }, + "peerDependenciesMeta": { + "enquirer": { + "optional": true + } + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/listr2/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -3633,6 +4620,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", @@ -3651,6 +4644,97 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3725,6 +4809,12 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3747,6 +4837,36 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -3901,6 +5021,18 @@ "node": ">= 10.13.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -3931,6 +5063,15 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3939,6 +5080,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -3956,6 +5112,12 @@ "node": ">= 0.8.0" } }, + "node_modules/ospath": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", + "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", + "dev": true + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3986,6 +5148,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4095,11 +5272,17 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "optional": true + "dev": true }, "node_modules/periscopic": { "version": "3.1.0", @@ -4406,6 +5589,27 @@ "svelte": "^3.2.0 || ^4.0.0-next.0" } }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -4421,6 +5625,28 @@ "node": "10.* || >= 12.*" } }, + "node_modules/proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", + "dev": true + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4430,6 +5656,27 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", + "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4521,15 +5768,6 @@ "rimraf": "bin.js" } }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "optional": true, - "dependencies": { - "performance-now": "^2.1.0" - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4586,6 +5824,21 @@ "node": ">= 10" } }, + "node_modules/request-progress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", + "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", + "dev": true, + "dependencies": { + "throttleit": "^1.0.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4623,6 +5876,25 @@ "node": ">= 10.13.0" } }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -4633,14 +5905,11 @@ "node": ">=0.10.0" } }, - "node_modules/rgbcolor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", - "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", - "optional": true, - "engines": { - "node": ">= 0.8.15" - } + "node_modules/rfdc": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", + "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", + "dev": true }, "node_modules/rimraf": { "version": "3.0.2", @@ -4746,6 +6015,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -4855,6 +6133,23 @@ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4876,6 +6171,24 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4918,6 +6231,20 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sorcery": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", @@ -4962,13 +6289,29 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, - "node_modules/stackblur-canvas": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", - "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", - "optional": true, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, "engines": { - "node": ">=0.1.14" + "node": ">=0.10.0" } }, "node_modules/stream-composer": { @@ -5092,6 +6435,15 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -5372,15 +6724,6 @@ "@types/estree": "*" } }, - "node_modules/svg-pathdata": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", - "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", - "optional": true, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -5519,15 +6862,6 @@ "streamx": "^2.12.5" } }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5555,6 +6889,21 @@ "node": ">=0.8" } }, + "node_modules/throttleit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", + "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -5582,6 +6931,15 @@ "@popperjs/core": "^2.9.0" } }, + "node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5614,6 +6972,30 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -5637,6 +7019,24 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5688,9 +7088,9 @@ } }, "node_modules/undici": { - "version": "5.28.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", - "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -5713,6 +7113,15 @@ "node": ">= 10.0.0" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -5752,21 +7161,22 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "optional": true, - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -5788,6 +7198,26 @@ "node": ">= 10.13.0" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true + }, "node_modules/vinyl": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", @@ -6477,6 +7907,16 @@ "node": ">= 6" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index aec1bf2a..e80e88ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.1.117", + "version": "0.1.123", "private": true, "scripts": { "dev": "vite dev --host", @@ -14,7 +14,8 @@ "lint:backend": "pylint backend/", "format": "prettier --plugin-search-dir --write '**/*.{js,ts,svelte,css,md,html,json}'", "format:backend": "black . --exclude \"/venv/\"", - "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write 'src/lib/i18n/**/*.{js,json}'" + "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write 'src/lib/i18n/**/*.{js,json}'", + "cy:open": "cypress open" }, "devDependencies": { "@sveltejs/adapter-auto": "^2.0.0", @@ -25,8 +26,10 @@ "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "autoprefixer": "^10.4.16", + "cypress": "^13.8.1", "eslint": "^8.56.0", "eslint-config-prettier": "^8.5.0", + "eslint-plugin-cypress": "^3.0.2", "eslint-plugin-svelte": "^2.30.0", "i18next-parser": "^8.13.0", "postcss": "^8.4.31", @@ -46,6 +49,7 @@ "async": "^3.2.5", "bits-ui": "^0.19.7", "dayjs": "^1.11.10", + "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", "highlight.js": "^11.9.0", "i18next": "^23.10.0", @@ -53,7 +57,6 @@ "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", - "jspdf": "^2.5.1", "katex": "^0.16.9", "marked": "^9.1.0", "svelte-sonner": "^0.3.19", diff --git a/run-compose.sh b/run-compose.sh index 08fba272..21574e95 100755 --- a/run-compose.sh +++ b/run-compose.sh @@ -82,6 +82,7 @@ usage() { echo "Examples:" echo " $0 --drop" echo " $0 --enable-gpu[count=1]" + echo " $0 --enable-gpu[count=all]" echo " $0 --enable-api[port=11435]" echo " $0 --enable-gpu[count=1] --enable-api[port=12345] --webui[port=3000]" echo " $0 --enable-gpu[count=1] --enable-api[port=12345] --webui[port=3000] --data[folder=./ollama-data]" @@ -160,7 +161,7 @@ else if [[ $enable_gpu == true ]]; then # Validate and process command-line arguments if [[ -n $gpu_count ]]; then - if ! [[ $gpu_count =~ ^[0-9]+$ ]]; then + if ! [[ $gpu_count =~ ^([0-9]+|all)$ ]]; then echo "Invalid GPU count: $gpu_count" exit 1 fi diff --git a/src/app.html b/src/app.html index f731761c..1aa01e8b 100644 --- a/src/app.html +++ b/src/app.html @@ -3,7 +3,7 @@ - + + Open WebUI + %sveltekit.head%
%sveltekit.body%
+ +
+ + + + + +
diff --git a/src/lib/apis/audio/index.ts b/src/lib/apis/audio/index.ts index d2848339..6679420d 100644 --- a/src/lib/apis/audio/index.ts +++ b/src/lib/apis/audio/index.ts @@ -1,11 +1,73 @@ import { AUDIO_API_BASE_URL } from '$lib/constants'; +export const getAudioConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${AUDIO_API_BASE_URL}/config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +type OpenAIConfigForm = { + url: string; + key: string; +}; + +export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm) => { + let error = null; + + const res = await fetch(`${AUDIO_API_BASE_URL}/config/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...payload + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const transcribeAudio = async (token: string, file: File) => { const data = new FormData(); data.append('file', file); let error = null; - const res = await fetch(`${AUDIO_API_BASE_URL}/transcribe`, { + const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, { method: 'POST', headers: { Accept: 'application/json', @@ -29,3 +91,40 @@ export const transcribeAudio = async (token: string, file: File) => { return res; }; + +export const synthesizeOpenAISpeech = async ( + token: string = '', + speaker: string = 'alloy', + text: string = '' +) => { + let error = null; + + const res = await fetch(`${AUDIO_API_BASE_URL}/speech`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'tts-1', + input: text, + voice: speaker + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res; + }) + .catch((err) => { + error = err.detail; + console.log(err); + + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 548a9418..26feb29b 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -58,7 +58,12 @@ export const userSignIn = async (email: string, password: string) => { return res; }; -export const userSignUp = async (name: string, email: string, password: string) => { +export const userSignUp = async ( + name: string, + email: string, + password: string, + profile_image_url: string +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signup`, { @@ -69,7 +74,47 @@ export const userSignUp = async (name: string, email: string, password: string) body: JSON.stringify({ name: name, email: email, - password: password + password: password, + profile_image_url: profile_image_url + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const addUser = async ( + token: string, + name: string, + email: string, + password: string, + role: string = 'pending' +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/add`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + name: name, + email: email, + password: password, + role: role }) }) .then(async (res) => { diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 28b3d4be..7b76b11f 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -62,6 +62,68 @@ export const getChatList = async (token: string = '') => { return res; }; +export const getChatListByUserId = async (token: string = '', userId: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/list/user/${userId}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getArchivedChatList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getAllChats = async (token: string) => { let error = null; @@ -282,6 +344,38 @@ export const shareChatById = async (token: string, id: string) => { return res; }; +export const archiveChatById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/archive`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const deleteSharedChatById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/images/index.ts b/src/lib/apis/images/index.ts index aadfafd1..3f624704 100644 --- a/src/lib/apis/images/index.ts +++ b/src/lib/apis/images/index.ts @@ -72,10 +72,10 @@ export const updateImageGenerationConfig = async ( return res; }; -export const getOpenAIKey = async (token: string = '') => { +export const getOpenAIConfig = async (token: string = '') => { let error = null; - const res = await fetch(`${IMAGES_API_BASE_URL}/key`, { + const res = await fetch(`${IMAGES_API_BASE_URL}/openai/config`, { method: 'GET', headers: { Accept: 'application/json', @@ -101,13 +101,13 @@ export const getOpenAIKey = async (token: string = '') => { throw error; } - return res.OPENAI_API_KEY; + return res; }; -export const updateOpenAIKey = async (token: string = '', key: string) => { +export const updateOpenAIConfig = async (token: string = '', url: string, key: string) => { let error = null; - const res = await fetch(`${IMAGES_API_BASE_URL}/key/update`, { + const res = await fetch(`${IMAGES_API_BASE_URL}/openai/config/update`, { method: 'POST', headers: { Accept: 'application/json', @@ -115,6 +115,7 @@ export const updateOpenAIKey = async (token: string = '', key: string) => { ...(token && { authorization: `Bearer ${token}` }) }, body: JSON.stringify({ + url: url, key: key }) }) @@ -136,7 +137,7 @@ export const updateOpenAIKey = async (token: string = '', key: string) => { throw error; } - return res.OPENAI_API_KEY; + return res; }; export const getImageGenerationEngineUrls = async (token: string = '') => { diff --git a/src/lib/apis/ollama/index.ts b/src/lib/apis/ollama/index.ts index 7c4e809e..a94aceac 100644 --- a/src/lib/apis/ollama/index.ts +++ b/src/lib/apis/ollama/index.ts @@ -1,4 +1,5 @@ import { OLLAMA_API_BASE_URL } from '$lib/constants'; +import { promptTemplate } from '$lib/utils'; export const getOllamaUrls = async (token: string = '') => { let error = null; @@ -144,7 +145,7 @@ export const generateTitle = async ( ) => { let error = null; - template = template.replace(/{{prompt}}/g, prompt); + template = promptTemplate(template, prompt); console.log(template); @@ -219,6 +220,32 @@ export const generatePrompt = async (token: string = '', model: string, conversa return res; }; +export const generateEmbeddings = async (token: string = '', model: string, text: string) => { + let error = null; + + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/embeddings`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + model: model, + prompt: text + }) + }).catch((err) => { + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const generateTextCompletion = async (token: string = '', model: string, text: string) => { let error = null; diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index 7d0d9415..ac770e5b 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -1,4 +1,5 @@ import { OPENAI_API_BASE_URL } from '$lib/constants'; +import { promptTemplate } from '$lib/utils'; export const getOpenAIUrls = async (token: string = '') => { let error = null; @@ -210,10 +211,12 @@ export const generateOpenAIChatCompletion = async ( token: string = '', body: object, url: string = OPENAI_API_BASE_URL -) => { +): Promise<[Response | null, AbortController]> => { + const controller = new AbortController(); let error = null; const res = await fetch(`${url}/chat/completions`, { + signal: controller.signal, method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -230,7 +233,7 @@ export const generateOpenAIChatCompletion = async ( throw error; } - return res; + return [res, controller]; }; export const synthesizeOpenAISpeech = async ( @@ -273,7 +276,7 @@ export const generateTitle = async ( ) => { let error = null; - template = template.replace(/{{prompt}}/g, prompt); + template = promptTemplate(template, prompt); console.log(template); diff --git a/src/lib/apis/rag/index.ts b/src/lib/apis/rag/index.ts index 668fe227..a9d163f8 100644 --- a/src/lib/apis/rag/index.ts +++ b/src/lib/apis/rag/index.ts @@ -123,6 +123,7 @@ export const getQuerySettings = async (token: string) => { type QuerySettings = { k: number | null; + r: number | null; template: string | null; }; @@ -220,6 +221,37 @@ export const uploadWebToVectorDB = async (token: string, collection_name: string return res; }; +export const uploadYoutubeTranscriptionToVectorDB = async (token: string, url: string) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/youtube`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + url: url + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const queryDoc = async ( token: string, collection_name: string, @@ -345,3 +377,132 @@ export const resetVectorDB = async (token: string) => { return res; }; + +export const getEmbeddingConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/embedding`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +type OpenAIConfigForm = { + key: string; + url: string; +}; + +type EmbeddingModelUpdateForm = { + openai_config?: OpenAIConfigForm; + embedding_engine: string; + embedding_model: string; +}; + +export const updateEmbeddingConfig = async (token: string, payload: EmbeddingModelUpdateForm) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/embedding/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...payload + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getRerankingConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/reranking`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +type RerankingModelUpdateForm = { + reranking_model: string; +}; + +export const updateRerankingConfig = async (token: string, payload: RerankingModelUpdateForm) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/reranking/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...payload + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/streaming/index.ts b/src/lib/apis/streaming/index.ts new file mode 100644 index 00000000..a72dbe47 --- /dev/null +++ b/src/lib/apis/streaming/index.ts @@ -0,0 +1,84 @@ +import { EventSourceParserStream } from 'eventsource-parser/stream'; +import type { ParsedEvent } from 'eventsource-parser'; + +type TextStreamUpdate = { + done: boolean; + value: string; +}; + +// createOpenAITextStream takes a responseBody with a SSE response, +// and returns an async generator that emits delta updates with large deltas chunked into random sized chunks +export async function createOpenAITextStream( + responseBody: ReadableStream, + splitLargeDeltas: boolean +): Promise> { + const eventStream = responseBody + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + let iterator = openAIStreamToIterator(eventStream); + if (splitLargeDeltas) { + iterator = streamLargeDeltasAsRandomChunks(iterator); + } + return iterator; +} + +async function* openAIStreamToIterator( + reader: ReadableStreamDefaultReader +): AsyncGenerator { + while (true) { + const { value, done } = await reader.read(); + if (done) { + yield { done: true, value: '' }; + break; + } + if (!value) { + continue; + } + const data = value.data; + if (data.startsWith('[DONE]')) { + yield { done: true, value: '' }; + break; + } + + try { + const parsedData = JSON.parse(data); + console.log(parsedData); + + yield { done: false, value: parsedData.choices?.[0]?.delta?.content ?? '' }; + } catch (e) { + console.error('Error extracting delta from SSE event:', e); + } + } +} + +// streamLargeDeltasAsRandomChunks will chunk large deltas (length > 5) into random sized chunks between 1-3 characters +// This is to simulate a more fluid streaming, even though some providers may send large chunks of text at once +async function* streamLargeDeltasAsRandomChunks( + iterator: AsyncGenerator +): AsyncGenerator { + for await (const textStreamUpdate of iterator) { + if (textStreamUpdate.done) { + yield textStreamUpdate; + return; + } + let content = textStreamUpdate.value; + if (content.length < 5) { + yield { done: false, value: content }; + continue; + } + while (content != '') { + const chunkSize = Math.min(Math.floor(Math.random() * 3) + 1, content.length); + const chunk = content.slice(0, chunkSize); + yield { done: false, value: chunk }; + // Do not sleep if the tab is hidden + // Timers are throttled to 1s in hidden tabs + if (document?.visibilityState !== 'hidden') { + await sleep(5); + } + content = content.slice(chunkSize); + } + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/lib/apis/utils/index.ts b/src/lib/apis/utils/index.ts index ef6b0d25..3f28d944 100644 --- a/src/lib/apis/utils/index.ts +++ b/src/lib/apis/utils/index.ts @@ -83,9 +83,9 @@ export const downloadDatabase = async (token: string) => { Authorization: `Bearer ${token}` } }) - .then((response) => { + .then(async (response) => { if (!response.ok) { - throw new Error('Network response was not ok'); + throw await response.json(); } return response.blob(); }) @@ -100,7 +100,11 @@ export const downloadDatabase = async (token: string) => { }) .catch((err) => { console.log(err); - error = err; + error = err.detail; return null; }); + + if (error) { + throw error; + } }; diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index 635d2c03..1113b25e 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -22,7 +22,7 @@ -
+
{$i18n.t('What’s New in')} @@ -32,6 +32,7 @@ diff --git a/src/lib/components/admin/AddUserModal.svelte b/src/lib/components/admin/AddUserModal.svelte new file mode 100644 index 00000000..4b505d02 --- /dev/null +++ b/src/lib/components/admin/AddUserModal.svelte @@ -0,0 +1,334 @@ + + + +
+
+
{$i18n.t('Add User')}
+ +
+ +
+
+
{ + submitHandler(); + }} + > +
+ + + +
+
+ {#if tab === ''} +
+
{$i18n.t('Role')}
+ +
+ +
+
+ +
+
{$i18n.t('Name')}
+ +
+ +
+
+ +
+ +
+
{$i18n.t('Email')}
+ +
+ +
+
+ +
+
{$i18n.t('Password')}
+ +
+ +
+
+ {:else if tab === 'import'} +
+
+ + + +
+ +
+ ⓘ {$i18n.t( + 'Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.' + )} + + Click here to download user import template file. + +
+
+ {/if} +
+ +
+ +
+
+
+
+
+
+ + diff --git a/src/lib/components/admin/EditUserModal.svelte b/src/lib/components/admin/EditUserModal.svelte index f5fa0837..dcaff95b 100644 --- a/src/lib/components/admin/EditUserModal.svelte +++ b/src/lib/components/admin/EditUserModal.svelte @@ -86,7 +86,7 @@
{$i18n.t('Created at')} - {dayjs(selectedUser.timestamp * 1000).format($i18n.t('MMMM DD, YYYY'))} + {dayjs(selectedUser.created_at * 1000).format($i18n.t('MMMM DD, YYYY'))}
@@ -139,7 +139,7 @@
+
+
+ +
+
+ {#if chats.length > 0} +
+
+ + + + + + + + + {#each chats as chat, idx} + + + + + + + + {/each} + +
{$i18n.t('Name')} +
+ +
+ {chat.title} +
+
+
+
+ + + +
+
+
+ +
+ {:else} +
+ {user.name} + {$i18n.t('has no conversations.')} +
+ {/if} +
+
+
+ diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index a23649d8..6711ea2b 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1,26 +1,34 @@ {#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} @@ -132,7 +143,30 @@ {/each} - {#if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} + {#if prompt.split(' ')?.at(0)?.substring(1).startsWith('https://www.youtube.com')} + + {:else if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} - {/each} + + {/each} +
- + {/if} {/if} diff --git a/src/lib/components/chat/MessageInput/Suggestions.svelte b/src/lib/components/chat/MessageInput/Suggestions.svelte index 13a8e1d7..f094d17a 100644 --- a/src/lib/components/chat/MessageInput/Suggestions.svelte +++ b/src/lib/components/chat/MessageInput/Suggestions.svelte @@ -1,56 +1,116 @@ -
-
+{#if prompts.length > 0} +
+ + Suggested +
+{/if} + +
+
{#each prompts as prompt, promptIdx} -
+
{/each} + +
+ + diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index 4cd97ca8..3ef99594 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -12,6 +12,7 @@ import Placeholder from './Messages/Placeholder.svelte'; import Spinner from '../common/Spinner.svelte'; import { imageGenerations } from '$lib/apis/images'; + import { copyToClipboard, findWordIndices } from '$lib/utils'; const i18n = getContext('i18n'); @@ -21,6 +22,8 @@ export let continueGeneration: Function; export let regenerateResponse: Function; + export let prompt; + export let suggestionPrompts; export let processing = ''; export let bottomPadding = false; export let autoScroll; @@ -42,40 +45,11 @@ element.scrollTop = element.scrollHeight; }; - const copyToClipboard = (text) => { - if (!navigator.clipboard) { - var textArea = document.createElement('textarea'); - textArea.value = text; - - // Avoid scrolling to bottom - textArea.style.top = '0'; - textArea.style.left = '0'; - textArea.style.position = 'fixed'; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - var successful = document.execCommand('copy'); - var msg = successful ? 'successful' : 'unsuccessful'; - console.log('Fallback: Copying text command was ' + msg); - } catch (err) { - console.error('Fallback: Oops, unable to copy', err); - } - - document.body.removeChild(textArea); - return; + const copyToClipboardWithToast = async (text) => { + const res = await copyToClipboard(text); + if (res) { + toast.success($i18n.t('Copying to clipboard was successful!')); } - navigator.clipboard.writeText(text).then( - function () { - console.log('Async: Copying to clipboard was successful!'); - toast.success($i18n.t('Copying to clipboard was successful!')); - }, - function (err) { - console.error('Async: Could not copy text: ', err); - } - ); }; const confirmEditMessage = async (messageId, content) => { @@ -107,12 +81,8 @@ await sendPrompt(userPrompt, userMessageId, chatId); }; - const confirmEditResponseMessage = async (messageId, content) => { - history.messages[messageId].originalContent = history.messages[messageId].content; - history.messages[messageId].content = content; - + const updateChatMessages = async () => { await tick(); - await updateChatById(localStorage.token, chatId, { messages: messages, history: history @@ -121,15 +91,20 @@ await chats.set(await getChatList(localStorage.token)); }; - const rateMessage = async (messageId, rating) => { - history.messages[messageId].rating = rating; - await tick(); - await updateChatById(localStorage.token, chatId, { - messages: messages, - history: history - }); + const confirmEditResponseMessage = async (messageId, content) => { + history.messages[messageId].originalContent = history.messages[messageId].content; + history.messages[messageId].content = content; - await chats.set(await getChatList(localStorage.token)); + await updateChatMessages(); + }; + + const rateMessage = async (messageId, rating) => { + history.messages[messageId].annotation = { + ...history.messages[messageId].annotation, + rating: rating + }; + + await updateChatMessages(); }; const showPreviousMessage = async (message) => { @@ -263,107 +238,111 @@ history: history }); }; - - // const messageDeleteHandler = async (messageId) => { - // const message = history.messages[messageId]; - // const parentId = message.parentId; - // const childrenIds = message.childrenIds ?? []; - // const grandchildrenIds = []; - - // // Iterate through childrenIds to find grandchildrenIds - // for (const childId of childrenIds) { - // const childMessage = history.messages[childId]; - // const grandChildrenIds = childMessage.childrenIds ?? []; - - // for (const grandchildId of grandchildrenIds) { - // const childMessage = history.messages[grandchildId]; - // childMessage.parentId = parentId; - // } - // grandchildrenIds.push(...grandChildrenIds); - // } - - // history.messages[parentId].childrenIds.push(...grandchildrenIds); - // history.messages[parentId].childrenIds = history.messages[parentId].childrenIds.filter( - // (id) => id !== messageId - // ); - - // // Select latest message - // let currentMessageId = grandchildrenIds.at(-1); - // if (currentMessageId) { - // let messageChildrenIds = history.messages[currentMessageId].childrenIds; - // while (messageChildrenIds.length !== 0) { - // currentMessageId = messageChildrenIds.at(-1); - // messageChildrenIds = history.messages[currentMessageId].childrenIds; - // } - // history.currentId = currentMessageId; - // } - - // await updateChatById(localStorage.token, chatId, { messages, history }); - // }; -{#if messages.length == 0} - -{:else} -
- {#key chatId} - {#each messages as message, messageIdx} -
-
- {#if message.role === 'user'} - messageDeleteHandler(message.id)} - user={$user} - {readOnly} - {message} - isFirstMessage={messageIdx === 0} - siblings={message.parentId !== null - ? history.messages[message.parentId]?.childrenIds ?? [] - : Object.values(history.messages) - .filter((message) => message.parentId === null) - .map((message) => message.id) ?? []} - {confirmEditMessage} - {showPreviousMessage} - {showNextMessage} - {copyToClipboard} - /> - {:else} - { - console.log('save', e); +
+ {#if messages.length == 0} + { + let text = p; - const message = e.detail; - history.messages[message.id] = message; - await updateChatById(localStorage.token, chatId, { - messages: messages, - history: history - }); - }} - /> - {/if} + if (p.includes('{{CLIPBOARD}}')) { + const clipboardText = await navigator.clipboard.readText().catch((err) => { + toast.error($i18n.t('Failed to read clipboard contents')); + return '{{CLIPBOARD}}'; + }); + + text = p.replaceAll('{{CLIPBOARD}}', clipboardText); + } + + prompt = text; + + await tick(); + + const chatInputElement = document.getElementById('chat-textarea'); + if (chatInputElement) { + prompt = p; + + chatInputElement.style.height = ''; + chatInputElement.style.height = Math.min(chatInputElement.scrollHeight, 200) + 'px'; + chatInputElement.focus(); + + const words = findWordIndices(prompt); + + if (words.length > 0) { + const word = words.at(0); + chatInputElement.setSelectionRange(word?.startIndex, word.endIndex + 1); + } + } + + await tick(); + }} + /> + {:else} +
+ {#key chatId} + {#each messages as message, messageIdx} +
+
+ {#if message.role === 'user'} + messageDeleteHandler(message.id)} + user={$user} + {readOnly} + {message} + isFirstMessage={messageIdx === 0} + siblings={message.parentId !== null + ? history.messages[message.parentId]?.childrenIds ?? [] + : Object.values(history.messages) + .filter((message) => message.parentId === null) + .map((message) => message.id) ?? []} + {confirmEditMessage} + {showPreviousMessage} + {showNextMessage} + copyToClipboard={copyToClipboardWithToast} + /> + {:else} + { + console.log('save', e); + + const message = e.detail; + history.messages[message.id] = message; + await updateChatById(localStorage.token, chatId, { + messages: messages, + history: history + }); + }} + /> + {/if} +
-
- {/each} + {/each} - {#if bottomPadding} -
- {/if} - {/key} -
-{/if} + {#if bottomPadding} +
+ {/if} + {/key} +
+ {/if} +
diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index c5290547..18ee9f54 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -31,7 +31,9 @@ >
-
{@html highlightedCode || code}
diff --git a/src/lib/components/chat/Messages/Placeholder.svelte b/src/lib/components/chat/Messages/Placeholder.svelte index 7e0a98cb..5035904d 100644 --- a/src/lib/components/chat/Messages/Placeholder.svelte +++ b/src/lib/components/chat/Messages/Placeholder.svelte @@ -3,11 +3,19 @@ import { user } from '$lib/stores'; import { onMount, getContext } from 'svelte'; + import { blur, fade } from 'svelte/transition'; + + import Suggestions from '../MessageInput/Suggestions.svelte'; + const i18n = getContext('i18n'); export let models = []; export let modelfiles = []; + export let submitPrompt; + export let suggestionPrompts; + + let mounted = false; let modelfile = null; let selectedModelIdx = 0; @@ -17,12 +25,16 @@ $: if (models.length > 0) { selectedModelIdx = models.length - 1; } + + onMount(() => { + mounted = true; + }); -{#if models.length > 0} -
-
-
+{#key mounted} +
+
+
{#each models as model, modelIdx}
-
- {#if modelfile} - - {modelfile.title} - -
- {modelfile.desc} -
- {#if modelfile.user} - - {/if} - {:else} -
{$i18n.t('Hello, {{name}}', { name: $user.name })}
-
{$i18n.t('How can I help you today?')}
- {/if} +
+
+
+ {#if modelfile} + {modelfile.title} + {:else} + {$i18n.t('Hello, {{name}}', { name: $user.name })} + {/if} +
+ +
+ {#if modelfile} +
+ {modelfile.desc} +
+ {#if modelfile.user} + + {/if} + {:else} +
+ {$i18n.t('How can I help you today?')} +
+ {/if} +
+
+
+ +
+
-{/if} +{/key} diff --git a/src/lib/components/chat/Messages/RateComment.svelte b/src/lib/components/chat/Messages/RateComment.svelte new file mode 100644 index 00000000..05eaae39 --- /dev/null +++ b/src/lib/components/chat/Messages/RateComment.svelte @@ -0,0 +1,129 @@ + + +
+
+
{$i18n.t('Tell us more:')}
+ + +
+ + {#if reasons.length > 0} +
+ {#each reasons as reason} + + {/each} +
+ {/if} + +
+