Browse Source

Merge pull request #1488 from open-webui/dev

0.1.118
Timothy Jaeryang Baek 1 year ago
parent
commit
78284e49d7
61 changed files with 1824 additions and 710 deletions
  1. 11 0
      .github/workflows/build-release.yml
  2. 335 17
      .github/workflows/docker-build.yaml
  3. 20 0
      CHANGELOG.md
  4. 86 50
      Dockerfile
  5. 14 11
      README.md
  6. 6 1
      backend/apps/audio/main.py
  7. 2 1
      backend/apps/ollama/main.py
  8. 45 27
      backend/apps/rag/main.py
  9. 42 0
      backend/apps/rag/utils.py
  10. 8 2
      backend/apps/web/models/auths.py
  11. 12 0
      backend/apps/web/models/chats.py
  12. 8 3
      backend/apps/web/models/users.py
  13. 5 1
      backend/apps/web/routers/auths.py
  14. 9 1
      backend/apps/web/routers/chats.py
  15. 44 21
      backend/config.py
  16. 16 11
      backend/main.py
  17. 16 6
      backend/start.sh
  18. 8 0
      docker-compose.amdgpu.yaml
  19. 2 2
      docker-compose.yaml
  20. 4 0
      kubernetes/helm/templates/_helpers.tpl
  21. 2 0
      kubernetes/helm/templates/ollama-service.yaml
  22. 2 0
      kubernetes/helm/templates/ollama-statefulset.yaml
  23. 2 0
      kubernetes/helm/templates/webui-pvc.yaml
  24. 1 0
      kubernetes/helm/values.yaml
  25. 5 5
      package-lock.json
  26. 1 1
      package.json
  27. 8 2
      src/lib/apis/auths/index.ts
  28. 61 0
      src/lib/apis/rag/index.ts
  29. 11 0
      src/lib/components/chat/MessageInput.svelte
  30. 14 12
      src/lib/components/chat/Messages.svelte
  31. 117 0
      src/lib/components/chat/Messages/RateComment.svelte
  32. 20 2
      src/lib/components/chat/Messages/ResponseMessage.svelte
  33. 247 190
      src/lib/components/chat/Settings/Account.svelte
  34. 1 1
      src/lib/components/chat/Settings/General.svelte
  35. 11 0
      src/lib/components/common/Modal.svelte
  36. 273 183
      src/lib/components/documents/Settings/General.svelte
  37. 1 1
      src/lib/i18n/locales/bg-BG/translation.json
  38. 1 1
      src/lib/i18n/locales/ca-ES/translation.json
  39. 1 1
      src/lib/i18n/locales/de-DE/translation.json
  40. 64 0
      src/lib/i18n/locales/en-GB/translation.json
  41. 10 1
      src/lib/i18n/locales/en-US/translation.json
  42. 1 1
      src/lib/i18n/locales/es-ES/translation.json
  43. 1 1
      src/lib/i18n/locales/fa-IR/translation.json
  44. 1 1
      src/lib/i18n/locales/fr-CA/translation.json
  45. 1 1
      src/lib/i18n/locales/fr-FR/translation.json
  46. 1 1
      src/lib/i18n/locales/it-IT/translation.json
  47. 1 1
      src/lib/i18n/locales/ja-JP/translation.json
  48. 1 1
      src/lib/i18n/locales/ko-KR/translation.json
  49. 4 0
      src/lib/i18n/locales/languages.json
  50. 1 1
      src/lib/i18n/locales/nl-NL/translation.json
  51. 1 1
      src/lib/i18n/locales/pt-BR/translation.json
  52. 1 1
      src/lib/i18n/locales/pt-PT/translation.json
  53. 1 1
      src/lib/i18n/locales/ru-RU/translation.json
  54. 1 1
      src/lib/i18n/locales/tr-TR/translation.json
  55. 1 1
      src/lib/i18n/locales/uk-UA/translation.json
  56. 1 1
      src/lib/i18n/locales/vi-VN/translation.json
  57. 1 1
      src/lib/i18n/locales/zh-CN/translation.json
  58. 1 1
      src/lib/i18n/locales/zh-TW/translation.json
  59. 76 0
      src/lib/utils/index.ts
  60. 175 136
      src/routes/(app)/admin/+page.svelte
  61. 7 4
      src/routes/auth/+page.svelte

+ 11 - 0
.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 }}',
+          })

+ 335 - 17
.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,30 +9,39 @@ 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: ghcr.io
   IMAGE_NAME: ${{ github.repository }}
+  FULL_IMAGE_NAME: ghcr.io/${{ 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
-    # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
     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:
@@ -41,12 +49,11 @@ jobs:
           username: ${{ github.actor }}
           password: ${{ secrets.GITHUB_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
@@ -56,11 +63,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.GITHUB_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.GITHUB_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.GITHUB_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.GITHUB_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.GITHUB_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 }}

+ 20 - 0
CHANGELOG.md

@@ -5,6 +5,26 @@ 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.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

+ 86 - 50
Dockerfile

@@ -1,82 +1,116 @@
 # 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 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.
+ARG USE_EMBEDDING_MODEL=all-MiniLM-L6-v2
 
-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 ""
-
-ENV OLLAMA_BASE_URL "/ollama"
-
-ENV OPENAI_API_BASE_URL ""
-ENV OPENAI_API_KEY ""
-
-ENV WEBUI_SECRET_KEY ""
-ENV WEBUI_AUTH_TRUSTED_EMAIL_HEADER ""
-
-ENV SCARF_NO_ANALYTICS true
-ENV DO_NOT_TRACK true
+# Use args
+ARG USE_CUDA
+ARG USE_OLLAMA
+ARG USE_CUDA_VER
+ARG USE_EMBEDDING_MODEL
+
+## 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}
+
+## Basis URL Config ##
+ENV OLLAMA_BASE_URL="/ollama" \
+    OPENAI_API_BASE_URL=""
+
+## API Key and Security Config ##
+ENV OPENAI_API_KEY="" \
+    WEBUI_SECRET_KEY="" \
+    SCARF_NO_ANALYTICS=true \
+    DO_NOT_TRACK=true
 
 # 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_EMBEDDING_MODEL_DIR="/app/backend/data/cache/embedding/models" \
+    SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models"
+#### Other models ##########################################################
 
 WORKDIR /app/backend
 
+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 torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir
-RUN pip3 install -r requirements.txt --no-cache-dir
+RUN 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 && \
+        pip3 install -r requirements.txt --no-cache-dir && \
+        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'])" && \
+        python -c "import os; from chromadb.utils import embedding_functions; sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=os.environ['RAG_EMBEDDING_MODEL'], device='cpu')"; \
+    else \
+        pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
+        pip3 install -r requirements.txt --no-cache-dir && \
+        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'])" && \
+        python -c "import os; from chromadb.utils import embedding_functions; sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=os.environ['RAG_EMBEDDING_MODEL'], device='cpu')"; \
+    fi
 
-# 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 +120,6 @@ COPY --from=build /app/package.json /app/package.json
 # copy backend files
 COPY ./backend .
 
-CMD [ "bash", "start.sh"]
+EXPOSE 8080
+
+CMD [ "bash", "start.sh"]

+ 14 - 11
README.md

@@ -94,24 +94,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
+```
+
+**If Ollama is on a Different Server**, use this command:
 
-- To connect to Ollama on another server, change the `OLLAMA_BASE_URL` to the server's URL:
+To connect to Ollama on another server, change the `OLLAMA_BASE_URL` to the server's URL:
 
-  ```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
-  ```
+```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! 😄
+After installation, you can access Open WebUI at [http://localhost:3000](http://localhost:3000). Enjoy! 😄
 
 #### Open WebUI: Server Connection Error
 

+ 6 - 1
backend/apps/audio/main.py

@@ -28,6 +28,7 @@ from config import (
     UPLOAD_DIR,
     WHISPER_MODEL,
     WHISPER_MODEL_DIR,
+    DEVICE_TYPE,
 )
 
 log = logging.getLogger(__name__)
@@ -42,6 +43,10 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
+# 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}")
+
 
 @app.post("/transcribe")
 def transcribe(
@@ -66,7 +71,7 @@ def transcribe(
 
         model = WhisperModel(
             WHISPER_MODEL,
-            device="auto",
+            device=whisper_device_type,
             compute_type="int8",
             download_root=WHISPER_MODEL_DIR,
         )

+ 2 - 1
backend/apps/ollama/main.py

@@ -215,7 +215,8 @@ 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, x["version"].split("-")[0].split("."))),
             )
 
             return {"version": lowest_version["version"]}

+ 45 - 27
backend/apps/rag/main.py

@@ -13,8 +13,8 @@ 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,
@@ -45,7 +45,7 @@ from apps.web.models.documents import (
     DocumentResponse,
 )
 
-from apps.rag.utils import query_doc, query_collection
+from apps.rag.utils import query_doc, query_collection, get_embedding_model_path
 
 from utils.misc import (
     calculate_sha256,
@@ -59,7 +59,8 @@ from config import (
     UPLOAD_DIR,
     DOCS_DIR,
     RAG_EMBEDDING_MODEL,
-    RAG_EMBEDDING_MODEL_DEVICE_TYPE,
+    RAG_EMBEDDING_MODEL_AUTO_UPDATE,
+    DEVICE_TYPE,
     CHROMA_CLIENT,
     CHUNK_SIZE,
     CHUNK_OVERLAP,
@@ -71,28 +72,25 @@ 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.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,
+        model_name=get_embedding_model_path(
+            app.state.RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL_AUTO_UPDATE
+        ),
+        device=DEVICE_TYPE,
     )
 )
 
@@ -143,18 +141,33 @@ class EmbeddingModelUpdateForm(BaseModel):
 async def update_embedding_model(
     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}"
     )
 
-    return {
-        "status": True,
-        "embedding_model": app.state.RAG_EMBEDDING_MODEL,
-    }
+    try:
+        sentence_transformer_ef = (
+            embedding_functions.SentenceTransformerEmbeddingFunction(
+                model_name=get_embedding_model_path(form_data.embedding_model, True),
+                device=DEVICE_TYPE,
+            )
+        )
+
+        app.state.RAG_EMBEDDING_MODEL = form_data.embedding_model
+        app.state.sentence_transformer_ef = sentence_transformer_ef
+
+        return {
+            "status": True,
+            "embedding_model": app.state.RAG_EMBEDDING_MODEL,
+        }
+
+    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),
+        )
 
 
 @app.get("/config")
@@ -341,9 +354,14 @@ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> b
             embedding_function=app.state.sentence_transformer_ef,
         )
 
-        collection.add(
-            documents=texts, metadatas=metadatas, ids=[str(uuid.uuid1()) for _ in texts]
-        )
+        for batch in create_batches(
+            api=CHROMA_CLIENT,
+            ids=[str(uuid.uuid1()) for _ in texts],
+            metadatas=metadatas,
+            documents=texts,
+        ):
+            collection.add(*batch)
+
         return True
     except Exception as e:
         log.exception(e)

+ 42 - 0
backend/apps/rag/utils.py

@@ -1,6 +1,8 @@
+import os
 import re
 import logging
 from typing import List
+from huggingface_hub import snapshot_download
 
 from config import SRC_LOG_LEVELS, CHROMA_CLIENT
 
@@ -188,3 +190,43 @@ def rag_messages(docs, messages, template, k, embedding_function):
     messages[last_user_message_idx] = new_user_message
 
     return messages
+
+
+def get_embedding_model_path(
+    embedding_model: str, update_embedding_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_embedding_model
+
+    snapshot_kwargs = {
+        "cache_dir": cache_dir,
+        "local_files_only": local_files_only,
+    }
+
+    log.debug(f"embedding_model: {embedding_model}")
+    log.debug(f"snapshot_kwargs: {snapshot_kwargs}")
+
+    # Inspiration from upstream sentence_transformers
+    if (
+        os.path.exists(embedding_model)
+        or ("\\" in embedding_model or embedding_model.count("/") > 1)
+        and local_files_only
+    ):
+        # If fully qualified path exists, return input, else set repo_id
+        return embedding_model
+    elif "/" not in embedding_model:
+        # Set valid repo_id for model short-name
+        embedding_model = "sentence-transformers" + "/" + embedding_model
+
+    snapshot_kwargs["repo_id"] = embedding_model
+
+    # Attempt to query the huggingface_hub library to determine the local path and/or to update
+    try:
+        embedding_model_repo_path = snapshot_download(**snapshot_kwargs)
+        log.debug(f"embedding_model_repo_path: {embedding_model_repo_path}")
+        return embedding_model_repo_path
+    except Exception as e:
+        log.exception(f"Cannot determine embedding model snapshot path: {e}")
+        return embedding_model

+ 8 - 2
backend/apps/web/models/auths.py

@@ -86,6 +86,7 @@ class SignupForm(BaseModel):
     name: str
     email: str
     password: str
+    profile_image_url: Optional[str] = "/user.png"
 
 
 class AuthsTable:
@@ -94,7 +95,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 +111,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

+ 12 - 0
backend/apps/web/models/chats.py

@@ -206,6 +206,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)

+ 8 - 3
backend/apps/web/models/users.py

@@ -31,7 +31,7 @@ class UserModel(BaseModel):
     name: str
     email: str
     role: str = "pending"
-    profile_image_url: str = "/user.png"
+    profile_image_url: str
     timestamp: int  # timestamp in epoch
     api_key: Optional[str] = None
 
@@ -59,7 +59,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,7 +72,7 @@ class UsersTable:
                 "name": name,
                 "email": email,
                 "role": role,
-                "profile_image_url": "/user.png",
+                "profile_image_url": profile_image_url,
                 "timestamp": int(time.time()),
             }
         )

+ 5 - 1
backend/apps/web/routers/auths.py

@@ -163,7 +163,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:

+ 9 - 1
backend/apps/web/routers/chats.py

@@ -251,7 +251,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)})

+ 44 - 21
backend/config.py

@@ -28,8 +28,6 @@ except ImportError:
 WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
 WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png"
 
-shutil.copyfile("../build/favicon.png", "./static/favicon.png")
-
 ####################################
 # ENV (dev,test,prod)
 ####################################
@@ -103,6 +101,26 @@ 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 = {}
+
+####################################
+# Static DIR
+####################################
+
+STATIC_DIR = str(Path(os.getenv("STATIC_DIR", "./static")).resolve())
+
+shutil.copyfile(f"{FRONTEND_BUILD_DIR}/favicon.png", f"{STATIC_DIR}/favicon.png")
 
 ####################################
 # LOGGING
@@ -165,7 +183,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)
 
@@ -177,18 +195,6 @@ 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
@@ -257,6 +263,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 +273,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"
 
@@ -391,10 +402,22 @@ 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"
+log.info(f"Embedding model set: {RAG_EMBEDDING_MODEL}"),
+
+RAG_EMBEDDING_MODEL_AUTO_UPDATE = (
+    os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "").lower() == "true"
 )
+
+
+# device type ebbeding 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"
+
+
 CHROMA_CLIENT = chromadb.PersistentClient(
     path=CHROMA_DATA_PATH,
     settings=Settings(allow_reset=True, anonymized_telemetry=False),

+ 16 - 11
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,6 +19,7 @@ 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.audio.main import app as audio_app
 from apps.images.main import app as images_app
@@ -38,6 +40,8 @@ from config import (
     VERSION,
     CHANGELOG,
     FRONTEND_BUILD_DIR,
+    CACHE_DIR,
+    STATIC_DIR,
     MODEL_FILTER_ENABLED,
     MODEL_FILTER_LIST,
     GLOBAL_LOG_LEVEL,
@@ -269,14 +273,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"]
-
-        return {"current": VERSION, "latest": latest_version[1:]}
-    except Exception as e:
+        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 aiohttp.ClientError as e:
         raise HTTPException(
             status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
             detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
@@ -297,9 +303,8 @@ async def get_manifest_json():
     }
 
 
-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")
 
 app.mount(
     "/",

+ 16 - 6
backend/start.sh

@@ -7,16 +7,26 @@ KEY_FILE=.webui_secret_key
 
 PORT="${PORT:-8080}"
 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 '*'
+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 0.0.0.0 --port "$PORT" --forwarded-allow-ips '*'

+ 8 - 0
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}'

+ 2 - 2
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

+ 4 - 0
kubernetes/helm/templates/_helpers.tpl

@@ -7,8 +7,12 @@ ollama
 {{- end -}}
 
 {{- define "ollama.url" -}}
+{{- 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" -}}
 {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}

+ 2 - 0
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 }}

+ 2 - 0
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 }}

+ 2 - 0
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 }}

+ 1 - 0
kubernetes/helm/values.yaml

@@ -1,6 +1,7 @@
 nameOverride: ""
 
 ollama:
+  externalHost: ""
   annotations: {}
   podAnnotations: {}
   replicaCount: 1

+ 5 - 5
package-lock.json

@@ -1,12 +1,12 @@
 {
 	"name": "open-webui",
-	"version": "0.1.117",
+	"version": "0.1.118",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "open-webui",
-			"version": "0.1.117",
+			"version": "0.1.118",
 			"dependencies": {
 				"@sveltejs/adapter-node": "^1.3.1",
 				"async": "^3.2.5",
@@ -5688,9 +5688,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"
 			},

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "open-webui",
-	"version": "0.1.117",
+	"version": "0.1.118",
 	"private": true,
 	"scripts": {
 		"dev": "vite dev --host",

+ 8 - 2
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,8 @@ 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) => {

+ 61 - 0
src/lib/apis/rag/index.ts

@@ -345,3 +345,64 @@ export const resetVectorDB = async (token: string) => {
 
 	return res;
 };
+
+export const getEmbeddingModel = async (token: string) => {
+	let error = null;
+
+	const res = await fetch(`${RAG_API_BASE_URL}/embedding/model`, {
+		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 EmbeddingModelUpdateForm = {
+	embedding_model: string;
+};
+
+export const updateEmbeddingModel = async (token: string, payload: EmbeddingModelUpdateForm) => {
+	let error = null;
+
+	const res = await fetch(`${RAG_API_BASE_URL}/embedding/model/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;
+};

+ 11 - 0
src/lib/components/chat/MessageInput.svelte

@@ -295,6 +295,13 @@
 
 		const dropZone = document.querySelector('body');
 
+		const handleKeyDown = (event: KeyboardEvent) => {
+			if (event.key === 'Escape') {
+				console.log('Escape');
+				dragged = false;
+			}
+		};
+
 		const onDragOver = (e) => {
 			e.preventDefault();
 			dragged = true;
@@ -350,11 +357,15 @@
 			dragged = false;
 		};
 
+		window.addEventListener('keydown', handleKeyDown);
+
 		dropZone?.addEventListener('dragover', onDragOver);
 		dropZone?.addEventListener('drop', onDrop);
 		dropZone?.addEventListener('dragleave', onDragLeave);
 
 		return () => {
+			window.removeEventListener('keydown', handleKeyDown);
+
 			dropZone?.removeEventListener('dragover', onDragOver);
 			dropZone?.removeEventListener('drop', onDrop);
 			dropZone?.removeEventListener('dragleave', onDragLeave);

+ 14 - 12
src/lib/components/chat/Messages.svelte

@@ -107,12 +107,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 +117,20 @@
 		await chats.set(await getChatList(localStorage.token));
 	};
 
+	const confirmEditResponseMessage = async (messageId, content) => {
+		history.messages[messageId].originalContent = history.messages[messageId].content;
+		history.messages[messageId].content = content;
+
+		await updateChatMessages();
+	};
+
 	const rateMessage = async (messageId, rating) => {
-		history.messages[messageId].rating = rating;
-		await tick();
-		await updateChatById(localStorage.token, chatId, {
-			messages: messages,
-			history: history
-		});
+		history.messages[messageId].annotation = {
+			...history.messages[messageId].annotation,
+			rating: rating
+		};
 
-		await chats.set(await getChatList(localStorage.token));
+		await updateChatMessages();
 	};
 
 	const showPreviousMessage = async (message) => {
@@ -338,6 +339,7 @@
 								siblings={history.messages[message.parentId]?.childrenIds ?? []}
 								isLastMessage={messageIdx + 1 === messages.length}
 								{readOnly}
+								{updateChatMessages}
 								{confirmEditResponseMessage}
 								{showPreviousMessage}
 								{showNextMessage}

+ 117 - 0
src/lib/components/chat/Messages/RateComment.svelte

@@ -0,0 +1,117 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+
+	import { createEventDispatcher, onMount } from 'svelte';
+
+	const dispatch = createEventDispatcher();
+
+	export let show = false;
+	export let message;
+
+	const LIKE_REASONS = [
+		`Accurate information`,
+		`Followed instructions perfectly`,
+		`Showcased creativity`,
+		`Positive attitude`,
+		`Attention to detail`,
+		`Thorough explanation`,
+		`Other`
+	];
+
+	const DISLIKE_REASONS = [
+		`Don't like the style`,
+		`Not factually correct`,
+		`Didn't fully follow instructions`,
+		`Refused when it shouldn't have`,
+		`Being Lazy`,
+		`Other`
+	];
+
+	let reasons = [];
+	let selectedReason = null;
+	let comment = '';
+
+	$: if (message.annotation.rating === 1) {
+		reasons = LIKE_REASONS;
+	} else if (message.annotation.rating === -1) {
+		reasons = DISLIKE_REASONS;
+	}
+
+	onMount(() => {
+		selectedReason = message.annotation.reason;
+		comment = message.annotation.comment;
+	});
+
+	const submitHandler = () => {
+		console.log('submitHandler');
+
+		message.annotation.reason = selectedReason;
+		message.annotation.comment = comment;
+
+		dispatch('submit');
+
+		toast.success('Thanks for your feedback!');
+		show = false;
+	};
+</script>
+
+<div class=" my-2.5 rounded-xl px-4 py-3 border dark:border-gray-850">
+	<div class="flex justify-between items-center">
+		<div class=" text-sm">Tell us more:</div>
+
+		<button
+			on:click={() => {
+				show = false;
+			}}
+		>
+			<svg
+				xmlns="http://www.w3.org/2000/svg"
+				fill="none"
+				viewBox="0 0 24 24"
+				stroke-width="1.5"
+				stroke="currentColor"
+				class="size-4"
+			>
+				<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+			</svg>
+		</button>
+	</div>
+
+	{#if reasons.length > 0}
+		<div class="flex flex-wrap gap-2 text-sm mt-2.5">
+			{#each reasons as reason}
+				<button
+					class="px-3.5 py-1 border dark:border-gray-850 dark:hover:bg-gray-850 {selectedReason ===
+					reason
+						? 'dark:bg-gray-800'
+						: ''} transition rounded-lg"
+					on:click={() => {
+						selectedReason = reason;
+					}}
+				>
+					{reason}
+				</button>
+			{/each}
+		</div>
+	{/if}
+
+	<div class="mt-2">
+		<textarea
+			bind:value={comment}
+			class="w-full text-sm px-1 py-2 bg-transparent outline-none resize-none rounded-xl"
+			placeholder="Feel free to add specific details"
+			rows="2"
+		/>
+	</div>
+
+	<div class="mt-2 flex justify-end">
+		<button
+			class=" bg-emerald-700 text-white text-sm font-medium rounded-lg px-3.5 py-1.5"
+			on:click={() => {
+				submitHandler();
+			}}
+		>
+			Submit
+		</button>
+	</div>
+</div>

+ 20 - 2
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -30,6 +30,7 @@
 	import Image from '$lib/components/common/Image.svelte';
 	import { WEBUI_BASE_URL } from '$lib/constants';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import RateComment from './RateComment.svelte';
 
 	export let modelfiles = [];
 	export let message;
@@ -39,6 +40,7 @@
 
 	export let readOnly = false;
 
+	export let updateChatMessages: Function;
 	export let confirmEditResponseMessage: Function;
 	export let showPreviousMessage: Function;
 	export let showNextMessage: Function;
@@ -60,6 +62,8 @@
 	let loadingSpeech = false;
 	let generatingImage = false;
 
+	let showRateComment = false;
+
 	$: tokens = marked.lexer(sanitizeResponseContent(message.content));
 
 	const renderer = new marked.Renderer();
@@ -536,11 +540,13 @@
 												<button
 													class="{isLastMessage
 														? 'visible'
-														: 'invisible group-hover:visible'} p-1 rounded {message.rating === 1
+														: 'invisible group-hover:visible'} p-1 rounded {message?.annotation
+														?.rating === 1
 														? 'bg-gray-100 dark:bg-gray-800'
 														: ''} dark:hover:text-white hover:text-black transition"
 													on:click={() => {
 														rateMessage(message.id, 1);
+														showRateComment = true;
 													}}
 												>
 													<svg
@@ -563,11 +569,13 @@
 												<button
 													class="{isLastMessage
 														? 'visible'
-														: 'invisible group-hover:visible'} p-1 rounded {message.rating === -1
+														: 'invisible group-hover:visible'} p-1 rounded {message?.annotation
+														?.rating === -1
 														? 'bg-gray-100 dark:bg-gray-800'
 														: ''} dark:hover:text-white hover:text-black transition"
 													on:click={() => {
 														rateMessage(message.id, -1);
+														showRateComment = true;
 													}}
 												>
 													<svg
@@ -824,6 +832,16 @@
 										{/if}
 									</div>
 								{/if}
+
+								{#if showRateComment}
+									<RateComment
+										bind:show={showRateComment}
+										bind:message
+										on:submit={() => {
+											updateChatMessages();
+										}}
+									/>
+								{/if}
 							</div>
 						{/if}
 					</div>

+ 247 - 190
src/lib/components/chat/Settings/Account.svelte

@@ -7,6 +7,7 @@
 
 	import UpdatePassword from './Account/UpdatePassword.svelte';
 	import { getGravatarUrl } from '$lib/apis/utils';
+	import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
 	import { copyToClipboard } from '$lib/utils';
 	import Plus from '$lib/components/icons/Plus.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -18,6 +19,8 @@
 	let profileImageUrl = '';
 	let name = '';
 
+	let showAPIKeys = false;
+
 	let showJWTToken = false;
 	let JWTTokenCopied = false;
 
@@ -28,6 +31,12 @@
 	let profileImageInputElement: HTMLInputElement;
 
 	const submitHandler = async () => {
+		if (name !== $user.name) {
+			if (profileImageUrl === generateInitialsImage($user.name) || profileImageUrl === '') {
+				profileImageUrl = generateInitialsImage(name);
+			}
+		}
+
 		const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
 			(error) => {
 				toast.error(error);
@@ -125,59 +134,93 @@
 			}}
 		/>
 
-		<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Profile')}</div>
-
-		<div class="flex space-x-5">
-			<div class="flex flex-col">
-				<div class="self-center">
-					<button
-						class="relative rounded-full dark:bg-gray-700"
-						type="button"
-						on:click={() => {
-							profileImageInputElement.click();
-						}}
-					>
-						<img
-							src={profileImageUrl !== '' ? profileImageUrl : '/user.png'}
-							alt="profile"
-							class=" rounded-full w-16 h-16 object-cover"
-						/>
+		<div class="space-y-1">
+			<!-- <div class=" text-sm font-medium">{$i18n.t('Account')}</div> -->
 
-						<div
-							class="absolute flex justify-center rounded-full bottom-0 left-0 right-0 top-0 h-full w-full overflow-hidden bg-gray-700 bg-fixed opacity-0 transition duration-300 ease-in-out hover:opacity-50"
+			<div class="flex space-x-5">
+				<div class="flex flex-col">
+					<div class="self-center mt-2">
+						<button
+							class="relative rounded-full dark:bg-gray-700"
+							type="button"
+							on:click={() => {
+								profileImageInputElement.click();
+							}}
 						>
-							<div class="my-auto text-gray-100">
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 20 20"
-									fill="currentColor"
-									class="w-5 h-5"
-								>
-									<path
-										d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
-									/>
-								</svg>
+							<img
+								src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(name)}
+								alt="profile"
+								class=" rounded-full size-16 object-cover"
+							/>
+
+							<div
+								class="absolute flex justify-center rounded-full bottom-0 left-0 right-0 top-0 h-full w-full overflow-hidden bg-gray-700 bg-fixed opacity-0 transition duration-300 ease-in-out hover:opacity-50"
+							>
+								<div class="my-auto text-gray-100">
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 20 20"
+										fill="currentColor"
+										class="w-5 h-5"
+									>
+										<path
+											d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
+										/>
+									</svg>
+								</div>
 							</div>
-						</div>
-					</button>
+						</button>
+					</div>
+				</div>
+
+				<div class="flex-1 flex flex-col self-center gap-0.5">
+					<div class=" mb-0.5 text-sm font-medium">{$i18n.t('Profile Image')}</div>
+
+					<div>
+						<button
+							class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-full px-4 py-0.5 bg-gray-100 dark:bg-gray-850"
+							on:click={async () => {
+								if (canvasPixelTest()) {
+									profileImageUrl = generateInitialsImage(name);
+								} else {
+									toast.info(
+										$i18n.t(
+											'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
+										),
+										{
+											duration: 1000 * 10
+										}
+									);
+								}
+							}}>{$i18n.t('Use Initials')}</button
+						>
+
+						<button
+							class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-full px-4 py-0.5 bg-gray-100 dark:bg-gray-850"
+							on:click={async () => {
+								const url = await getGravatarUrl($user.email);
+
+								profileImageUrl = url;
+							}}>{$i18n.t('Use Gravatar')}</button
+						>
+
+						<button
+							class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg px-2 py-1"
+							on:click={async () => {
+								profileImageUrl = '/user.png';
+							}}>{$i18n.t('Remove')}</button
+						>
+					</div>
 				</div>
-				<button
-					class=" text-xs text-gray-600"
-					on:click={async () => {
-						const url = await getGravatarUrl($user.email);
-
-						profileImageUrl = url;
-					}}>{$i18n.t('Use Gravatar')}</button
-				>
 			</div>
 
-			<div class="flex-1">
+			<div class="pt-0.5">
 				<div class="flex flex-col w-full">
-					<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
+					<div class=" mb-1 text-xs font-medium">{$i18n.t('Name')}</div>
 
 					<div class="flex-1">
 						<input
-							class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
 							type="text"
 							bind:value={name}
 							required
@@ -187,133 +230,46 @@
 			</div>
 		</div>
 
-		<hr class=" dark:border-gray-700 my-4" />
-		<UpdatePassword />
+		<div class="py-0.5">
+			<UpdatePassword />
+		</div>
 
 		<hr class=" dark:border-gray-700 my-4" />
 
-		<div class="flex flex-col gap-4">
-			<div class="justify-between w-full">
-				<div class="flex justify-between w-full">
-					<div class="self-center text-xs font-medium">{$i18n.t('JWT Token')}</div>
-				</div>
-
-				<div class="flex mt-2">
-					<div class="flex w-full">
-						<input
-							class="w-full rounded-l-lg py-1.5 pl-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-800 outline-none"
-							type={showJWTToken ? 'text' : 'password'}
-							value={localStorage.token}
-							disabled
-						/>
+		<div class="flex justify-between items-center text-sm">
+			<div class="  font-medium">{$i18n.t('API keys')}</div>
+			<button
+				class=" text-xs font-medium text-gray-500"
+				type="button"
+				on:click={() => {
+					showAPIKeys = !showAPIKeys;
+				}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
+			>
+		</div>
 
-						<button
-							class="px-2 transition rounded-r-lg bg-white dark:bg-gray-800"
-							on:click={() => {
-								showJWTToken = !showJWTToken;
-							}}
-						>
-							{#if showJWTToken}
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 16 16"
-									fill="currentColor"
-									class="w-4 h-4"
-								>
-									<path
-										fill-rule="evenodd"
-										d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l10.5 10.5a.75.75 0 1 0 1.06-1.06l-1.322-1.323a7.012 7.012 0 0 0 2.16-3.11.87.87 0 0 0 0-.567A7.003 7.003 0 0 0 4.82 3.76l-1.54-1.54Zm3.196 3.195 1.135 1.136A1.502 1.502 0 0 1 9.45 8.389l1.136 1.135a3 3 0 0 0-4.109-4.109Z"
-										clip-rule="evenodd"
-									/>
-									<path
-										d="m7.812 10.994 1.816 1.816A7.003 7.003 0 0 1 1.38 8.28a.87.87 0 0 1 0-.566 6.985 6.985 0 0 1 1.113-2.039l2.513 2.513a3 3 0 0 0 2.806 2.806Z"
-									/>
-								</svg>
-							{:else}
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 16 16"
-									fill="currentColor"
-									class="w-4 h-4"
-								>
-									<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
-									<path
-										fill-rule="evenodd"
-										d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
-										clip-rule="evenodd"
-									/>
-								</svg>
-							{/if}
-						</button>
+		{#if showAPIKeys}
+			<div class="flex flex-col gap-4">
+				<div class="justify-between w-full">
+					<div class="flex justify-between w-full">
+						<div class="self-center text-xs font-medium">{$i18n.t('JWT Token')}</div>
 					</div>
 
-					<button
-						class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-800 transition rounded-lg"
-						on:click={() => {
-							copyToClipboard(localStorage.token);
-							JWTTokenCopied = true;
-							setTimeout(() => {
-								JWTTokenCopied = false;
-							}, 2000);
-						}}
-					>
-						{#if JWTTokenCopied}
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								viewBox="0 0 20 20"
-								fill="currentColor"
-								class="w-4 h-4"
-							>
-								<path
-									fill-rule="evenodd"
-									d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
-									clip-rule="evenodd"
-								/>
-							</svg>
-						{:else}
-							<svg
-								xmlns="http://www.w3.org/2000/svg"
-								viewBox="0 0 16 16"
-								fill="currentColor"
-								class="w-4 h-4"
-							>
-								<path
-									fill-rule="evenodd"
-									d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
-									clip-rule="evenodd"
-								/>
-								<path
-									fill-rule="evenodd"
-									d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
-									clip-rule="evenodd"
-								/>
-							</svg>
-						{/if}
-					</button>
-				</div>
-			</div>
-			<div class="justify-between w-full">
-				<div class="flex justify-between w-full">
-					<div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
-				</div>
-
-				<div class="flex mt-2">
-					{#if APIKey}
+					<div class="flex mt-2">
 						<div class="flex w-full">
 							<input
-								class="w-full rounded-l-lg py-1.5 pl-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-800 outline-none"
-								type={showAPIKey ? 'text' : 'password'}
-								value={APIKey}
+								class="w-full rounded-l-lg py-1.5 pl-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-none"
+								type={showJWTToken ? 'text' : 'password'}
+								value={localStorage.token}
 								disabled
 							/>
 
 							<button
-								class="px-2 transition rounded-r-lg bg-white dark:bg-gray-800"
+								class="px-2 transition rounded-r-lg bg-white dark:bg-gray-850"
 								on:click={() => {
-									showAPIKey = !showAPIKey;
+									showJWTToken = !showJWTToken;
 								}}
 							>
-								{#if showAPIKey}
+								{#if showJWTToken}
 									<svg
 										xmlns="http://www.w3.org/2000/svg"
 										viewBox="0 0 16 16"
@@ -348,16 +304,16 @@
 						</div>
 
 						<button
-							class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-800 transition rounded-lg"
+							class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
 							on:click={() => {
-								copyToClipboard(APIKey);
-								APIKeyCopied = true;
+								copyToClipboard(localStorage.token);
+								JWTTokenCopied = true;
 								setTimeout(() => {
-									APIKeyCopied = false;
+									JWTTokenCopied = false;
 								}, 2000);
 							}}
 						>
-							{#if APIKeyCopied}
+							{#if JWTTokenCopied}
 								<svg
 									xmlns="http://www.w3.org/2000/svg"
 									viewBox="0 0 20 20"
@@ -390,45 +346,146 @@
 								</svg>
 							{/if}
 						</button>
+					</div>
+				</div>
+				<div class="justify-between w-full">
+					<div class="flex justify-between w-full">
+						<div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
+					</div>
+
+					<div class="flex mt-2">
+						{#if APIKey}
+							<div class="flex w-full">
+								<input
+									class="w-full rounded-l-lg py-1.5 pl-4 text-sm bg-white dark:text-gray-300 dark:bg-gray-850 outline-none"
+									type={showAPIKey ? 'text' : 'password'}
+									value={APIKey}
+									disabled
+								/>
+
+								<button
+									class="px-2 transition rounded-r-lg bg-white dark:bg-gray-850"
+									on:click={() => {
+										showAPIKey = !showAPIKey;
+									}}
+								>
+									{#if showAPIKey}
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 16 16"
+											fill="currentColor"
+											class="w-4 h-4"
+										>
+											<path
+												fill-rule="evenodd"
+												d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l10.5 10.5a.75.75 0 1 0 1.06-1.06l-1.322-1.323a7.012 7.012 0 0 0 2.16-3.11.87.87 0 0 0 0-.567A7.003 7.003 0 0 0 4.82 3.76l-1.54-1.54Zm3.196 3.195 1.135 1.136A1.502 1.502 0 0 1 9.45 8.389l1.136 1.135a3 3 0 0 0-4.109-4.109Z"
+												clip-rule="evenodd"
+											/>
+											<path
+												d="m7.812 10.994 1.816 1.816A7.003 7.003 0 0 1 1.38 8.28a.87.87 0 0 1 0-.566 6.985 6.985 0 0 1 1.113-2.039l2.513 2.513a3 3 0 0 0 2.806 2.806Z"
+											/>
+										</svg>
+									{:else}
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 16 16"
+											fill="currentColor"
+											class="w-4 h-4"
+										>
+											<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
+											<path
+												fill-rule="evenodd"
+												d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
+												clip-rule="evenodd"
+											/>
+										</svg>
+									{/if}
+								</button>
+							</div>
 
-						<Tooltip content="Create new key">
 							<button
-								class=" px-1.5 py-1 dark:hover:bg-gray-800transition rounded-lg"
+								class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
 								on:click={() => {
-									createAPIKeyHandler();
+									copyToClipboard(APIKey);
+									APIKeyCopied = true;
+									setTimeout(() => {
+										APIKeyCopied = false;
+									}, 2000);
 								}}
 							>
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									fill="none"
-									viewBox="0 0 24 24"
-									stroke-width="2"
-									stroke="currentColor"
-									class="size-4"
-								>
-									<path
-										stroke-linecap="round"
-										stroke-linejoin="round"
-										d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
-									/>
-								</svg>
+								{#if APIKeyCopied}
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 20 20"
+										fill="currentColor"
+										class="w-4 h-4"
+									>
+										<path
+											fill-rule="evenodd"
+											d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
+											clip-rule="evenodd"
+										/>
+									</svg>
+								{:else}
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 16 16"
+										fill="currentColor"
+										class="w-4 h-4"
+									>
+										<path
+											fill-rule="evenodd"
+											d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
+											clip-rule="evenodd"
+										/>
+										<path
+											fill-rule="evenodd"
+											d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
+											clip-rule="evenodd"
+										/>
+									</svg>
+								{/if}
 							</button>
-						</Tooltip>
-					{:else}
-						<button
-							class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition"
-							on:click={() => {
-								createAPIKeyHandler();
-							}}
-						>
-							<Plus strokeWidth="2" className=" size-3.5" />
 
-							Create new secret key</button
-						>
-					{/if}
+							<Tooltip content="Create new key">
+								<button
+									class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
+									on:click={() => {
+										createAPIKeyHandler();
+									}}
+								>
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										fill="none"
+										viewBox="0 0 24 24"
+										stroke-width="2"
+										stroke="currentColor"
+										class="size-4"
+									>
+										<path
+											stroke-linecap="round"
+											stroke-linejoin="round"
+											d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
+										/>
+									</svg>
+								</button>
+							</Tooltip>
+						{:else}
+							<button
+								class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
+								on:click={() => {
+									createAPIKeyHandler();
+								}}
+							>
+								<Plus strokeWidth="2" className=" size-3.5" />
+
+								Create new secret key</button
+							>
+						{/if}
+					</div>
 				</div>
 			</div>
-		</div>
+		{/if}
 	</div>
 
 	<div class="flex justify-end pt-3 text-sm font-medium">

+ 1 - 1
src/lib/components/chat/Settings/General.svelte

@@ -185,7 +185,7 @@
 
 			<div>
 				<div class=" py-0.5 flex w-full justify-between">
-					<div class=" self-center text-xs font-medium">{$i18n.t('Desktop Notifications')}</div>
+					<div class=" self-center text-xs font-medium">{$i18n.t('Notifications')}</div>
 
 					<button
 						class="p-1 px-3 text-xs flex rounded transition"

+ 11 - 0
src/lib/components/common/Modal.svelte

@@ -7,6 +7,7 @@
 	export let show = true;
 	export let size = 'md';
 
+	let modalElement = null;
 	let mounted = false;
 
 	const sizeToWidth = (size) => {
@@ -19,14 +20,23 @@
 		}
 	};
 
+	const handleKeyDown = (event: KeyboardEvent) => {
+		if (event.key === 'Escape') {
+			console.log('Escape');
+			show = false;
+		}
+	};
+
 	onMount(() => {
 		mounted = true;
 	});
 
 	$: if (mounted) {
 		if (show) {
+			window.addEventListener('keydown', handleKeyDown);
 			document.body.style.overflow = 'hidden';
 		} else {
+			window.removeEventListener('keydown', handleKeyDown);
 			document.body.style.overflow = 'unset';
 		}
 	}
@@ -36,6 +46,7 @@
 	<!-- svelte-ignore a11y-click-events-have-key-events -->
 	<!-- svelte-ignore a11y-no-static-element-interactions -->
 	<div
+		bind:this={modalElement}
 		class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full min-h-screen h-screen flex justify-center z-50 overflow-hidden overscroll-contain"
 		in:fade={{ duration: 10 }}
 		on:click={() => {

+ 273 - 183
src/lib/components/documents/Settings/General.svelte

@@ -6,18 +6,23 @@
 		getQuerySettings,
 		scanDocs,
 		updateQuerySettings,
-		resetVectorDB
+		resetVectorDB,
+		getEmbeddingModel,
+		updateEmbeddingModel
 	} from '$lib/apis/rag';
 
 	import { documents } from '$lib/stores';
 	import { onMount, getContext } from 'svelte';
 	import { toast } from 'svelte-sonner';
 
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+
 	const i18n = getContext('i18n');
 
 	export let saveHandler: Function;
 
-	let loading = false;
+	let scanDirLoading = false;
+	let updateEmbeddingModelLoading = false;
 
 	let showResetConfirm = false;
 
@@ -30,10 +35,12 @@
 		k: 4
 	};
 
+	let embeddingModel = '';
+
 	const scanHandler = async () => {
-		loading = true;
+		scanDirLoading = true;
 		const res = await scanDocs(localStorage.token);
-		loading = false;
+		scanDirLoading = false;
 
 		if (res) {
 			await documents.set(await getDocs(localStorage.token));
@@ -41,6 +48,38 @@
 		}
 	};
 
+	const embeddingModelUpdateHandler = async () => {
+		if (embeddingModel.split('/').length - 1 > 1) {
+			toast.error(
+				$i18n.t(
+					'Model filesystem path detected. Model shortname is required for update, cannot continue.'
+				)
+			);
+			return;
+		}
+
+		console.log('Update embedding model attempt:', embeddingModel);
+
+		updateEmbeddingModelLoading = true;
+		const res = await updateEmbeddingModel(localStorage.token, {
+			embedding_model: embeddingModel
+		}).catch(async (error) => {
+			toast.error(error);
+			embeddingModel = (await getEmbeddingModel(localStorage.token)).embedding_model;
+			return null;
+		});
+		updateEmbeddingModelLoading = false;
+
+		if (res) {
+			console.log('embeddingModelUpdateHandler:', res);
+			if (res.status === true) {
+				toast.success($i18n.t('Model {{embedding_model}} update complete!', res), {
+					duration: 1000 * 10
+				});
+			}
+		}
+	};
+
 	const submitHandler = async () => {
 		const res = await updateRAGConfig(localStorage.token, {
 			pdf_extract_images: pdfExtractImages,
@@ -62,6 +101,8 @@
 			chunkOverlap = res.chunk.chunk_overlap;
 		}
 
+		embeddingModel = (await getEmbeddingModel(localStorage.token)).embedding_model;
+
 		querySettings = await getQuerySettings(localStorage.token);
 	});
 </script>
@@ -73,7 +114,7 @@
 		saveHandler();
 	}}
 >
-	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
+	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[22rem]">
 		<div>
 			<div class=" mb-2 text-sm font-medium">{$i18n.t('General Settings')}</div>
 
@@ -83,7 +124,7 @@
 				</div>
 
 				<button
-					class=" self-center text-xs p-1 px-3 bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 rounded flex flex-row space-x-1 items-center {loading
+					class=" self-center text-xs p-1 px-3 bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 rounded-lg flex flex-row space-x-1 items-center {scanDirLoading
 						? ' cursor-not-allowed'
 						: ''}"
 					on:click={() => {
@@ -91,24 +132,11 @@
 						console.log('check');
 					}}
 					type="button"
-					disabled={loading}
+					disabled={scanDirLoading}
 				>
 					<div class="self-center font-medium">{$i18n.t('Scan')}</div>
 
-					<!-- <svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 16 16"
-						fill="currentColor"
-						class="w-3 h-3"
-					>
-						<path
-							fill-rule="evenodd"
-							d="M13.836 2.477a.75.75 0 0 1 .75.75v3.182a.75.75 0 0 1-.75.75h-3.182a.75.75 0 0 1 0-1.5h1.37l-.84-.841a4.5 4.5 0 0 0-7.08.932.75.75 0 0 1-1.3-.75 6 6 0 0 1 9.44-1.242l.842.84V3.227a.75.75 0 0 1 .75-.75Zm-.911 7.5A.75.75 0 0 1 13.199 11a6 6 0 0 1-9.44 1.241l-.84-.84v1.371a.75.75 0 0 1-1.5 0V9.591a.75.75 0 0 1 .75-.75H5.35a.75.75 0 0 1 0 1.5H3.98l.841.841a4.5 4.5 0 0 0 7.08-.932.75.75 0 0 1 1.025-.273Z"
-							clip-rule="evenodd"
-						/>
-					</svg> -->
-
-					{#if loading}
+					{#if scanDirLoading}
 						<div class="ml-3 self-center">
 							<svg
 								class=" w-3 h-3"
@@ -141,196 +169,258 @@
 
 		<hr class=" dark:border-gray-700" />
 
-		<div class=" ">
-			<div class=" text-sm font-medium">{$i18n.t('Chunk Params')}</div>
-
-			<div class=" flex">
-				<div class="  flex w-full justify-between">
-					<div class="self-center text-xs font-medium min-w-fit">{$i18n.t('Chunk Size')}</div>
-
-					<div class="self-center p-3">
-						<input
-							class=" w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
-							type="number"
-							placeholder={$i18n.t('Enter Chunk Size')}
-							bind:value={chunkSize}
-							autocomplete="off"
-							min="0"
-						/>
-					</div>
-				</div>
-
+		<div class="space-y-2">
+			<div>
+				<div class=" mb-2 text-sm font-medium">{$i18n.t('Update Embedding Model')}</div>
 				<div class="flex w-full">
-					<div class=" self-center text-xs font-medium min-w-fit">{$i18n.t('Chunk Overlap')}</div>
-
-					<div class="self-center p-3">
+					<div class="flex-1 mr-2">
 						<input
-							class="w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
-							type="number"
-							placeholder={$i18n.t('Enter Chunk Overlap')}
-							bind:value={chunkOverlap}
-							autocomplete="off"
-							min="0"
+							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+							placeholder={$i18n.t('Update embedding model (e.g. {{model}})', {
+								model: embeddingModel.slice(-40)
+							})}
+							bind:value={embeddingModel}
 						/>
 					</div>
-				</div>
-			</div>
-
-			<div>
-				<div class="flex justify-between items-center text-xs">
-					<div class=" text-xs font-medium">{$i18n.t('PDF Extract Images (OCR)')}</div>
-
 					<button
-						class=" text-xs font-medium text-gray-500"
-						type="button"
+						class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
 						on:click={() => {
-							pdfExtractImages = !pdfExtractImages;
-						}}>{pdfExtractImages ? $i18n.t('On') : $i18n.t('Off')}</button
+							embeddingModelUpdateHandler();
+						}}
+						disabled={updateEmbeddingModelLoading}
 					>
+						{#if updateEmbeddingModelLoading}
+							<div class="self-center">
+								<svg
+									class=" w-4 h-4"
+									viewBox="0 0 24 24"
+									fill="currentColor"
+									xmlns="http://www.w3.org/2000/svg"
+									><style>
+										.spinner_ajPY {
+											transform-origin: center;
+											animation: spinner_AtaB 0.75s infinite linear;
+										}
+										@keyframes spinner_AtaB {
+											100% {
+												transform: rotate(360deg);
+											}
+										}
+									</style><path
+										d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+										opacity=".25"
+									/><path
+										d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+										class="spinner_ajPY"
+									/></svg
+								>
+							</div>
+						{:else}
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 16 16"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path
+									d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
+								/>
+								<path
+									d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+								/>
+							</svg>
+						{/if}
+					</button>
 				</div>
-			</div>
-		</div>
 
-		<div>
-			<div class=" text-sm font-medium">{$i18n.t('Query Params')}</div>
+				<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
+					{$i18n.t(
+						'Warning: If you update or change your embedding model, you will need to re-import all documents.'
+					)}
+				</div>
 
-			<div class=" flex">
-				<div class="  flex w-full justify-between">
-					<div class="self-center text-xs font-medium flex-1">{$i18n.t('Top K')}</div>
+				<hr class=" dark:border-gray-700 my-3" />
+
+				<div class=" ">
+					<div class=" text-sm font-medium">{$i18n.t('Chunk Params')}</div>
+
+					<div class=" flex">
+						<div class="  flex w-full justify-between">
+							<div class="self-center text-xs font-medium min-w-fit">{$i18n.t('Chunk Size')}</div>
+
+							<div class="self-center p-3">
+								<input
+									class=" w-full rounded-lg py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+									type="number"
+									placeholder={$i18n.t('Enter Chunk Size')}
+									bind:value={chunkSize}
+									autocomplete="off"
+									min="0"
+								/>
+							</div>
+						</div>
 
-					<div class="self-center p-3">
-						<input
-							class=" w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
-							type="number"
-							placeholder={$i18n.t('Enter Top K')}
-							bind:value={querySettings.k}
-							autocomplete="off"
-							min="0"
-						/>
+						<div class="flex w-full">
+							<div class=" self-center text-xs font-medium min-w-fit">
+								{$i18n.t('Chunk Overlap')}
+							</div>
+
+							<div class="self-center p-3">
+								<input
+									class="w-full rounded-lg py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+									type="number"
+									placeholder={$i18n.t('Enter Chunk Overlap')}
+									bind:value={chunkOverlap}
+									autocomplete="off"
+									min="0"
+								/>
+							</div>
+						</div>
 					</div>
-				</div>
 
-				<!-- <div class="flex w-full">
-						<div class=" self-center text-xs font-medium min-w-fit">Chunk Overlap</div>
-	
-						<div class="self-center p-3">
-							<input
-								class="w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600"
-								type="number"
-								placeholder="Enter Chunk Overlap"
-								bind:value={chunkOverlap}
-								autocomplete="off"
-								min="0"
-							/>
-						</div>
-					</div> -->
-			</div>
+					<div class="pr-2">
+						<div class="flex justify-between items-center text-xs">
+							<div class=" text-xs font-medium">{$i18n.t('PDF Extract Images (OCR)')}</div>
 
-			<div>
-				<div class=" mb-2.5 text-sm font-medium">{$i18n.t('RAG Template')}</div>
-				<textarea
-					bind:value={querySettings.template}
-					class="w-full rounded p-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
-					rows="4"
-				/>
-			</div>
-		</div>
+							<button
+								class=" text-xs font-medium text-gray-500"
+								type="button"
+								on:click={() => {
+									pdfExtractImages = !pdfExtractImages;
+								}}>{pdfExtractImages ? $i18n.t('On') : $i18n.t('Off')}</button
+							>
+						</div>
+					</div>
+				</div>
 
-		<hr class=" dark:border-gray-700" />
+				<hr class=" dark:border-gray-700 my-3" />
+
+				<div>
+					<div class=" text-sm font-medium">{$i18n.t('Query Params')}</div>
+
+					<div class=" flex">
+						<div class="  flex w-full justify-between">
+							<div class="self-center text-xs font-medium flex-1">{$i18n.t('Top K')}</div>
+
+							<div class="self-center p-3">
+								<input
+									class=" w-full rounded-lg py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+									type="number"
+									placeholder={$i18n.t('Enter Top K')}
+									bind:value={querySettings.k}
+									autocomplete="off"
+									min="0"
+								/>
+							</div>
+						</div>
+					</div>
 
-		{#if showResetConfirm}
-			<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
-				<div class="flex items-center space-x-3">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 16 16"
-						fill="currentColor"
-						class="w-4 h-4"
-					>
-						<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
-						<path
-							fill-rule="evenodd"
-							d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
-							clip-rule="evenodd"
+					<div>
+						<div class=" mb-2.5 text-sm font-medium">{$i18n.t('RAG Template')}</div>
+						<textarea
+							bind:value={querySettings.template}
+							class="w-full rounded-lg px-4 py-3 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none resize-none"
+							rows="4"
 						/>
-					</svg>
-					<span>{$i18n.t('Are you sure?')}</span>
+					</div>
 				</div>
 
-				<div class="flex space-x-1.5 items-center">
-					<button
-						class="hover:text-white transition"
-						on:click={() => {
-							const res = resetVectorDB(localStorage.token).catch((error) => {
-								toast.error(error);
-								return null;
-							});
+				<hr class=" dark:border-gray-700 my-3" />
+
+				{#if showResetConfirm}
+					<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
+						<div class="flex items-center space-x-3">
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 16 16"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
+								<path
+									fill-rule="evenodd"
+									d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
+									clip-rule="evenodd"
+								/>
+							</svg>
+							<span>{$i18n.t('Are you sure?')}</span>
+						</div>
 
-							if (res) {
-								toast.success($i18n.t('Success'));
-							}
+						<div class="flex space-x-1.5 items-center">
+							<button
+								class="hover:text-white transition"
+								on:click={() => {
+									const res = resetVectorDB(localStorage.token).catch((error) => {
+										toast.error(error);
+										return null;
+									});
+
+									if (res) {
+										toast.success($i18n.t('Success'));
+									}
 
-							showResetConfirm = false;
-						}}
-					>
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								fill-rule="evenodd"
-								d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
-								clip-rule="evenodd"
-							/>
-						</svg>
-					</button>
+									showResetConfirm = false;
+								}}
+							>
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 20 20"
+									fill="currentColor"
+									class="w-4 h-4"
+								>
+									<path
+										fill-rule="evenodd"
+										d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
+										clip-rule="evenodd"
+									/>
+								</svg>
+							</button>
+							<button
+								class="hover:text-white transition"
+								on:click={() => {
+									showResetConfirm = false;
+								}}
+							>
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 20 20"
+									fill="currentColor"
+									class="w-4 h-4"
+								>
+									<path
+										d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+									/>
+								</svg>
+							</button>
+						</div>
+					</div>
+				{:else}
 					<button
-						class="hover:text-white transition"
+						class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
 						on:click={() => {
-							showResetConfirm = false;
+							showResetConfirm = true;
 						}}
 					>
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
-							/>
-						</svg>
+						<div class=" self-center mr-3">
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 16 16"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path
+									fill-rule="evenodd"
+									d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
+									clip-rule="evenodd"
+								/>
+							</svg>
+						</div>
+						<div class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
 					</button>
-				</div>
+				{/if}
 			</div>
-		{:else}
-			<button
-				class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
-				on:click={() => {
-					showResetConfirm = true;
-				}}
-			>
-				<div class=" self-center mr-3">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 16 16"
-						fill="currentColor"
-						class="w-4 h-4"
-					>
-						<path
-							fill-rule="evenodd"
-							d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
-							clip-rule="evenodd"
-						/>
-					</svg>
-				</div>
-				<div class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
-			</button>
-		{/if}
+		</div>
 	</div>
-
 	<div class="flex justify-end pt-3 text-sm font-medium">
 		<button
 			class=" px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded"

+ 1 - 1
src/lib/i18n/locales/bg-BG/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "Изтрито {{deleteModelTag}}",
 	"Deleted {tagName}": "Изтрито {tagName}",
 	"Description": "Описание",
-	"Desktop Notifications": "Десктоп Известия",
+	"Notifications": "Десктоп Известия",
 	"Disabled": "Деактивиран",
 	"Discover a modelfile": "Откриване на модфайл",
 	"Discover a prompt": "Откриване на промпт",

+ 1 - 1
src/lib/i18n/locales/ca-ES/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "Esborrat {{deleteModelTag}}",
 	"Deleted {tagName}": "Esborrat {tagName}",
 	"Description": "Descripció",
-	"Desktop Notifications": "Notificacions d'Escriptori",
+	"Notifications": "Notificacions d'Escriptori",
 	"Disabled": "Desactivat",
 	"Discover a modelfile": "Descobreix un fitxer de model",
 	"Discover a prompt": "Descobreix un prompt",

+ 1 - 1
src/lib/i18n/locales/de-DE/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} gelöscht",
 	"Deleted {tagName}": "{tagName} gelöscht",
 	"Description": "Beschreibung",
-	"Desktop Notifications": "Desktop-Benachrichtigungen",
+	"Notifications": "Desktop-Benachrichtigungen",
 	"Disabled": "Deaktiviert",
 	"Discover a modelfile": "Eine Modelfiles entdecken",
 	"Discover a prompt": "Einen Prompt entdecken",

+ 64 - 0
src/lib/i18n/locales/en-GB/translation.json

@@ -0,0 +1,64 @@
+{
+	"analyze": "analyse",
+	"analyzed": "analysed",
+	"analyzes": "analyses",
+	"apologize": "apologise",
+	"apologized": "apologised",
+	"apologizes": "apologises",
+	"apologizing": "apologising",
+	"canceled": "cancelled",
+	"canceling": "cancelling",
+	"capitalize": "capitalise",
+	"capitalized": "capitalised",
+	"capitalizes": "capitalises",
+	"center": "centre",
+	"centered": "centred",
+	"color": "colour",
+	"colorize": "colourise",
+	"customize": "customise",
+	"customizes": "customises",
+	"defense": "defence",
+	"dialog": "dialogue",
+	"emphasize": "emphasise",
+	"emphasized": "emphasised",
+	"emphasizes": "emphasises",
+	"favor": "favour",
+	"favorable": "favourable",
+	"favorite": "favourite",
+	"favoritism": "favouritism",
+	"labor": "labour",
+	"labored": "laboured",
+	"laboring": "labouring",
+	"maximize": "maximise",
+	"maximizes": "maximises",
+	"minimize": "minimise",
+	"minimizes": "minimises",
+	"neighbor": "neighbour",
+	"neighborhood": "neighbourhood",
+	"offense": "offence",
+	"organize": "organise",
+	"organizes": "organises",
+	"personalize": "personalise",
+	"personalizes": "personalises",
+	"program": "programme",
+	"programmed": "programmed",
+	"programs": "programmes",
+	"quantization": "quantisation",
+	"quantize": "quantise",
+	"randomize": "randomise",
+	"randomizes": "randomises",
+	"realize": "realise",
+	"realizes": "realises",
+	"recognize": "recognise",
+	"recognizes": "recognises",
+	"summarize": "summarise",
+	"summarizes": "summarises",
+	"theater": "theatre",
+	"theaters": "theatres",
+	"toward": "towards",
+	"traveled": "travelled",
+	"traveler": "traveller",
+	"traveling": "travelling",
+	"utilize": "utilise",
+	"utilizes": "utilises"
+}

+ 10 - 1
src/lib/i18n/locales/en-US/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "",
 	"Deleted {tagName}": "",
 	"Description": "",
-	"Desktop Notifications": "",
+	"Notifications": "",
 	"Disabled": "",
 	"Discover a modelfile": "",
 	"Discover a prompt": "",
@@ -120,6 +120,7 @@
 	"Edit Doc": "",
 	"Edit User": "",
 	"Email": "",
+	"Embedding model: {{embedding_model}}": "",
 	"Enable Chat History": "",
 	"Enable New Sign Ups": "",
 	"Enabled": "",
@@ -150,6 +151,7 @@
 	"Failed to read clipboard contents": "",
 	"File Mode": "",
 	"File not found.": "",
+	"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "",
 	"Focus chat input": "",
 	"Format your variables using square brackets like this:": "",
 	"From (Base Model)": "",
@@ -193,8 +195,11 @@
 	"MMMM DD, YYYY": "",
 	"Model '{{modelName}}' has been successfully downloaded.": "",
 	"Model '{{modelTag}}' is already in queue for downloading.": "",
+	"Model {{embedding_model}} update complete!": "",
+	"Model {{embedding_model}} update failed or not required!": "",
 	"Model {{modelId}} not found": "",
 	"Model {{modelName}} already exists.": "",
+	"Model filesystem path detected. Model shortname is required for update, cannot continue.": "",
 	"Model Name": "",
 	"Model not selected": "",
 	"Model Tag Name": "",
@@ -332,7 +337,10 @@
 	"TTS Settings": "",
 	"Type Hugging Face Resolve (Download) URL": "",
 	"Uh-oh! There was an issue connecting to {{provider}}.": "",
+	"Understand that updating or changing your embedding model requires reset of the vector database and re-import of all documents. You have been warned!": "",
 	"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "",
+	"Update": "",
+	"Update embedding model {{embedding_model}}": "",
 	"Update password": "",
 	"Upload a GGUF model": "",
 	"Upload files": "",
@@ -340,6 +348,7 @@
 	"URL Mode": "",
 	"Use '#' in the prompt input to load and select your documents.": "",
 	"Use Gravatar": "",
+	"Use Initials": "",
 	"user": "",
 	"User Permissions": "",
 	"Users": "",

+ 1 - 1
src/lib/i18n/locales/es-ES/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "Se borró {{deleteModelTag}}",
 	"Deleted {tagName}": "Se borró {tagName}",
 	"Description": "Descripción",
-	"Desktop Notifications": "Notificaciones",
+	"Notifications": "Notificaciones",
 	"Disabled": "Desactivado",
 	"Discover a modelfile": "Descubre un modelfile",
 	"Discover a prompt": "Descubre un Prompt",

+ 1 - 1
src/lib/i18n/locales/fa-IR/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} پاک شد",
 	"Deleted {tagName}": "{tagName} حذف شد",
 	"Description": "توضیحات",
-	"Desktop Notifications": "اعلان",
+	"Notifications": "اعلان",
 	"Disabled": "غیرفعال",
 	"Discover a modelfile": "فایل مدل را کشف کنید",
 	"Discover a prompt": "یک اعلان را کشف کنید",

+ 1 - 1
src/lib/i18n/locales/fr-CA/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} supprimé",
 	"Deleted {tagName}": "{tagName} supprimé",
 	"Description": "Description",
-	"Desktop Notifications": "Notifications de bureau",
+	"Notifications": "Notifications de bureau",
 	"Disabled": "Désactivé",
 	"Discover a modelfile": "Découvrir un fichier de modèle",
 	"Discover a prompt": "Découvrir un prompt",

+ 1 - 1
src/lib/i18n/locales/fr-FR/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} supprimé",
 	"Deleted {tagName}": "{tagName} supprimé",
 	"Description": "Description",
-	"Desktop Notifications": "Notifications de bureau",
+	"Notifications": "Notifications de bureau",
 	"Disabled": "Désactivé",
 	"Discover a modelfile": "Découvrir un fichier de modèle",
 	"Discover a prompt": "Découvrir un prompt",

+ 1 - 1
src/lib/i18n/locales/it-IT/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "Eliminato {{deleteModelTag}}",
 	"Deleted {tagName}": "Eliminato {tagName}",
 	"Description": "Descrizione",
-	"Desktop Notifications": "Notifiche desktop",
+	"Notifications": "Notifiche desktop",
 	"Disabled": "Disabilitato",
 	"Discover a modelfile": "Scopri un file modello",
 	"Discover a prompt": "Scopri un prompt",

+ 1 - 1
src/lib/i18n/locales/ja-JP/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} を削除しました",
 	"Deleted {tagName}": "{tagName} を削除しました",
 	"Description": "説明",
-	"Desktop Notifications": "デスクトップ通知",
+	"Notifications": "デスクトップ通知",
 	"Disabled": "無効",
 	"Discover a modelfile": "モデルファイルを見つける",
 	"Discover a prompt": "プロンプトを見つける",

+ 1 - 1
src/lib/i18n/locales/ko-KR/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} 삭제됨",
 	"Deleted {tagName}": "{tagName} 삭제됨",
 	"Description": "설명",
-	"Desktop Notifications": "알림",
+	"Notifications": "알림",
 	"Disabled": "비활성화",
 	"Discover a modelfile": "모델파일 검색",
 	"Discover a prompt": "프롬프트 검색",

+ 4 - 0
src/lib/i18n/locales/languages.json

@@ -15,6 +15,10 @@
 		"code": "de-DE",
 		"title": "Deutsch"
 	},
+	{
+		"code": "en-GB",
+		"title": "English (GB)"
+	},
 	{
 		"code": "es-ES",
 		"title": "Spanish"

+ 1 - 1
src/lib/i18n/locales/nl-NL/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} is verwijderd",
 	"Deleted {tagName}": "{tagName} is verwijderd",
 	"Description": "Beschrijving",
-	"Desktop Notifications": "Desktop Notificaties",
+	"Notifications": "Desktop Notificaties",
 	"Disabled": "Uitgeschakeld",
 	"Discover a modelfile": "Ontdek een modelfile",
 	"Discover a prompt": "Ontdek een prompt",

+ 1 - 1
src/lib/i18n/locales/pt-BR/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} excluído",
 	"Deleted {tagName}": "{tagName} excluído",
 	"Description": "Descrição",
-	"Desktop Notifications": "Notificações da Área de Trabalho",
+	"Notifications": "Notificações da Área de Trabalho",
 	"Disabled": "Desativado",
 	"Discover a modelfile": "Descobrir um arquivo de modelo",
 	"Discover a prompt": "Descobrir um prompt",

+ 1 - 1
src/lib/i18n/locales/pt-PT/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} excluído",
 	"Deleted {tagName}": "{tagName} excluído",
 	"Description": "Descrição",
-	"Desktop Notifications": "Notificações da Área de Trabalho",
+	"Notifications": "Notificações da Área de Trabalho",
 	"Disabled": "Desativado",
 	"Discover a modelfile": "Descobrir um arquivo de modelo",
 	"Discover a prompt": "Descobrir um prompt",

+ 1 - 1
src/lib/i18n/locales/ru-RU/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "Удалено {{deleteModelTag}}",
 	"Deleted {tagName}": "Удалено {tagName}",
 	"Description": "Описание",
-	"Desktop Notifications": "Уведомления на рабочем столе",
+	"Notifications": "Уведомления на рабочем столе",
 	"Disabled": "Отключено",
 	"Discover a modelfile": "Найти файл модели",
 	"Discover a prompt": "Найти промт",

+ 1 - 1
src/lib/i18n/locales/tr-TR/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} silindi",
 	"Deleted {tagName}": "{tagName} silindi",
 	"Description": "Açıklama",
-	"Desktop Notifications": "Masaüstü Bildirimleri",
+	"Notifications": "Bildirimler",
 	"Disabled": "Devre Dışı",
 	"Discover a modelfile": "Bir model dosyası keşfedin",
 	"Discover a prompt": "Bir prompt keşfedin",

+ 1 - 1
src/lib/i18n/locales/uk-UA/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "Видалено {{deleteModelTag}}",
 	"Deleted {tagName}": "Видалено {tagName}",
 	"Description": "Опис",
-	"Desktop Notifications": "Сповіщення",
+	"Notifications": "Сповіщення",
 	"Disabled": "Вимкнено",
 	"Discover a modelfile": "Знайти файл моделі",
 	"Discover a prompt": "Знайти промт",

+ 1 - 1
src/lib/i18n/locales/vi-VN/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "Đã xóa {{deleteModelTag}}",
 	"Deleted {tagName}": "Đã xóa {tagName}",
 	"Description": "Mô tả",
-	"Desktop Notifications": "Thông báo trên máy tính (Notification)",
+	"Notifications": "Thông báo trên máy tính (Notification)",
 	"Disabled": "Đã vô hiệu hóa",
 	"Discover a modelfile": "Khám phá thêm các mô hình mới",
 	"Discover a prompt": "Khám phá thêm prompt mới",

+ 1 - 1
src/lib/i18n/locales/zh-CN/translation.json

@@ -100,7 +100,7 @@
 	"Deleted {{deleteModelTag}}": "已删除{{deleteModelTag}}",
 	"Deleted {tagName}": "已删除{tagName}",
 	"Description": "描述",
-	"Desktop Notifications": "桌面通知",
+	"Notifications": "桌面通知",
 	"Disabled": "禁用",
 	"Discover a modelfile": "探索模型文件",
 	"Discover a prompt": "探索提示词",

+ 1 - 1
src/lib/i18n/locales/zh-TW/translation.json

@@ -101,7 +101,7 @@
 	"Deleted {{deleteModelTag}}": "已刪除 {{deleteModelTag}}",
 	"Deleted {tagName}": "已刪除 {tagName}",
 	"Description": "描述",
-	"Desktop Notifications": "桌面通知",
+	"Notifications": "桌面通知",
 	"Disabled": "已停用",
 	"Discover a modelfile": "發現新 Modelfile",
 	"Discover a prompt": "發現新提示詞",

+ 76 - 0
src/lib/utils/index.ts

@@ -111,6 +111,82 @@ export const getGravatarURL = (email) => {
 	return `https://www.gravatar.com/avatar/${hash}`;
 };
 
+export const canvasPixelTest = () => {
+	// Test a 1x1 pixel to potentially identify browser/plugin fingerprint blocking or spoofing
+	// Inspiration: https://github.com/kkapsner/CanvasBlocker/blob/master/test/detectionTest.js
+	const canvas = document.createElement('canvas');
+	const ctx = canvas.getContext('2d');
+	canvas.height = 1;
+	canvas.width = 1;
+	const imageData = new ImageData(canvas.width, canvas.height);
+	const pixelValues = imageData.data;
+
+	// Generate RGB test data
+	for (let i = 0; i < imageData.data.length; i += 1) {
+		if (i % 4 !== 3) {
+			pixelValues[i] = Math.floor(256 * Math.random());
+		} else {
+			pixelValues[i] = 255;
+		}
+	}
+
+	ctx.putImageData(imageData, 0, 0);
+	const p = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+
+	// Read RGB data and fail if unmatched
+	for (let i = 0; i < p.length; i += 1) {
+		if (p[i] !== pixelValues[i]) {
+			console.log(
+				'canvasPixelTest: Wrong canvas pixel RGB value detected:',
+				p[i],
+				'at:',
+				i,
+				'expected:',
+				pixelValues[i]
+			);
+			console.log('canvasPixelTest: Canvas blocking or spoofing is likely');
+			return false;
+		}
+	}
+
+	return true;
+};
+
+export const generateInitialsImage = (name) => {
+	const canvas = document.createElement('canvas');
+	const ctx = canvas.getContext('2d');
+	canvas.width = 100;
+	canvas.height = 100;
+
+	if (!canvasPixelTest()) {
+		console.log(
+			'generateInitialsImage: failed pixel test, fingerprint evasion is likely. Using default image.'
+		);
+		return '/user.png';
+	}
+
+	ctx.fillStyle = '#F39C12';
+	ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+	ctx.fillStyle = '#FFFFFF';
+	ctx.font = '40px Helvetica';
+	ctx.textAlign = 'center';
+	ctx.textBaseline = 'middle';
+
+	const sanitizedName = name.trim();
+	const initials =
+		sanitizedName.length > 0
+			? sanitizedName[0] +
+			  (sanitizedName.split(' ').length > 1
+					? sanitizedName[sanitizedName.lastIndexOf(' ') + 1]
+					: '')
+			: '';
+
+	ctx.fillText(initials.toUpperCase(), canvas.width / 2, canvas.height / 2);
+
+	return canvas.toDataURL();
+};
+
 export const copyToClipboard = (text) => {
 	if (!navigator.clipboard) {
 		const textArea = document.createElement('textarea');

+ 175 - 136
src/routes/(app)/admin/+page.svelte

@@ -4,6 +4,8 @@
 	import { goto } from '$app/navigation';
 	import { onMount, getContext } from 'svelte';
 
+	import dayjs from 'dayjs';
+
 	import { toast } from 'svelte-sonner';
 
 	import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users';
@@ -16,6 +18,7 @@
 	let loaded = false;
 	let users = [];
 
+	let search = '';
 	let selectedUser = null;
 
 	let showSettingsModal = false;
@@ -80,157 +83,193 @@
 
 <SettingsModal bind:show={showSettingsModal} />
 
-<div class="min-h-screen max-h-[100dvh] w-full flex justify-center dark:text-white font-mona">
+<div class="min-h-screen max-h-[100dvh] w-full flex justify-center dark:text-white">
 	{#if loaded}
 		<div class=" flex flex-col justify-between w-full overflow-y-auto">
-			<div class="max-w-2xl mx-auto w-full px-3 md:px-0 my-10">
+			<div class=" mx-auto w-full">
 				<div class="w-full">
 					<div class=" flex flex-col justify-center">
-						<div class=" flex justify-between items-center">
-							<div class="flex items-center text-2xl font-semibold">
-								{$i18n.t('All Users')}
-								<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
-								<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
-									>{users.length}</span
-								>
-							</div>
-							<div>
-								<button
-									class="flex items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 transition"
-									type="button"
-									on:click={() => {
-										showSettingsModal = !showSettingsModal;
-									}}
-								>
-									<svg
-										xmlns="http://www.w3.org/2000/svg"
-										viewBox="0 0 16 16"
-										fill="currentColor"
-										class="w-4 h-4"
+						<div class=" px-5 pt-3">
+							<div class=" flex justify-between items-center">
+								<div class="flex items-center text-2xl font-semibold">Dashboard</div>
+								<div>
+									<button
+										class="flex items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 transition"
+										type="button"
+										on:click={() => {
+											showSettingsModal = !showSettingsModal;
+										}}
 									>
-										<path
-											fill-rule="evenodd"
-											d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
-											clip-rule="evenodd"
-										/>
-									</svg>
-
-									<div class=" text-xs">{$i18n.t('Admin Settings')}</div>
-								</button>
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 16 16"
+											fill="currentColor"
+											class="w-4 h-4"
+										>
+											<path
+												fill-rule="evenodd"
+												d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
+												clip-rule="evenodd"
+											/>
+										</svg>
+
+										<div class=" text-xs">{$i18n.t('Admin Settings')}</div>
+									</button>
+								</div>
 							</div>
 						</div>
-						<div class=" text-gray-500 text-xs mt-1">
-							ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
+
+						<div class="px-5 flex text-sm gap-2.5">
+							<div class="py-3 border-b font-medium text-gray-100 cursor-pointer">Overview</div>
+							<!-- <div class="py-3 text-gray-300 cursor-pointer">Users</div> -->
 						</div>
 
-						<hr class=" my-3 dark:border-gray-600" />
-
-						<div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap">
-							<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
-								<thead
-									class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
-								>
-									<tr>
-										<th scope="col" class="px-3 py-2"> {$i18n.t('Role')} </th>
-										<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
-										<th scope="col" class="px-3 py-2"> {$i18n.t('Email')} </th>
-										<th scope="col" class="px-3 py-2"> {$i18n.t('Action')} </th>
-									</tr>
-								</thead>
-								<tbody>
-									{#each users as user}
-										<tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700 text-xs">
-											<td class="px-3 py-2 min-w-[7rem] w-28">
-												<button
-													class=" flex items-center gap-2 text-xs px-3 py-0.5 rounded-lg {user.role ===
-														'admin' &&
-														'text-sky-600 dark:text-sky-200 bg-sky-200/30'} {user.role === 'user' &&
-														'text-green-600 dark:text-green-200 bg-green-200/30'} {user.role ===
-														'pending' && 'text-gray-600 dark:text-gray-200 bg-gray-200/30'}"
-													on:click={() => {
-														if (user.role === 'user') {
-															updateRoleHandler(user.id, 'admin');
-														} else if (user.role === 'pending') {
-															updateRoleHandler(user.id, 'user');
-														} else {
-															updateRoleHandler(user.id, 'pending');
-														}
-													}}
-												>
-													<div
-														class="w-1 h-1 rounded-full {user.role === 'admin' &&
-															'bg-sky-600 dark:bg-sky-300'} {user.role === 'user' &&
-															'bg-green-600 dark:bg-green-300'} {user.role === 'pending' &&
-															'bg-gray-600 dark:bg-gray-300'}"
-													/>
-													{$i18n.t(user.role)}</button
-												>
-											</td>
-											<td class="px-3 py-2 font-medium text-gray-900 dark:text-white w-max">
-												<div class="flex flex-row w-max">
-													<img
-														class=" rounded-full w-6 h-6 object-cover mr-2.5"
-														src={user.profile_image_url}
-														alt="user"
-													/>
-
-													<div class=" font-medium self-center">{user.name}</div>
-												</div>
-											</td>
-											<td class=" px-3 py-2"> {user.email} </td>
-
-											<td class="px-3 py-2">
-												<div class="flex justify-start w-full">
+						<hr class=" mb-3 dark:border-gray-800" />
+
+						<div class="px-5">
+							<div class="mt-0.5 mb-3 flex justify-between">
+								<div class="flex text-lg font-medium px-0.5">
+									{$i18n.t('All Users')}
+									<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
+									<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
+										>{users.length}</span
+									>
+								</div>
+
+								<div class="">
+									<input
+										class=" w-60 rounded-lg py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+										placeholder={$i18n.t('Search')}
+										bind:value={search}
+									/>
+								</div>
+							</div>
+
+							<div class="scrollbar-hidden relative overflow-x-auto whitespace-nowrap">
+								<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
+									<thead
+										class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400"
+									>
+										<tr>
+											<th scope="col" class="px-3 py-2"> {$i18n.t('Role')} </th>
+											<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
+											<th scope="col" class="px-3 py-2"> {$i18n.t('Email')} </th>
+											<th scope="col" class="px-3 py-2"> {$i18n.t('Created at')} </th>
+											<th scope="col" class="px-3 py-2 text-right" />
+										</tr>
+									</thead>
+									<tbody>
+										{#each users.filter((user) => {
+											if (search === '') {
+												return true;
+											} else {
+												let name = user.name.toLowerCase();
+												const query = search.toLowerCase();
+												return name.includes(query);
+											}
+										}) as user}
+											<tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700 text-xs">
+												<td class="px-3 py-2 min-w-[7rem] w-28">
 													<button
-														class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
-														on:click={async () => {
-															showEditUserModal = !showEditUserModal;
-															selectedUser = user;
+														class=" flex items-center gap-2 text-xs px-3 py-0.5 rounded-lg {user.role ===
+															'admin' &&
+															'text-sky-600 dark:text-sky-200 bg-sky-200/30'} {user.role ===
+															'user' &&
+															'text-green-600 dark:text-green-200 bg-green-200/30'} {user.role ===
+															'pending' && 'text-gray-600 dark:text-gray-200 bg-gray-200/30'}"
+														on:click={() => {
+															if (user.role === 'user') {
+																updateRoleHandler(user.id, 'admin');
+															} else if (user.role === 'pending') {
+																updateRoleHandler(user.id, 'user');
+															} else {
+																updateRoleHandler(user.id, 'pending');
+															}
 														}}
 													>
-														<svg
-															xmlns="http://www.w3.org/2000/svg"
-															fill="none"
-															viewBox="0 0 24 24"
-															stroke-width="1.5"
-															stroke="currentColor"
-															class="w-4 h-4"
+														<div
+															class="w-1 h-1 rounded-full {user.role === 'admin' &&
+																'bg-sky-600 dark:bg-sky-300'} {user.role === 'user' &&
+																'bg-green-600 dark:bg-green-300'} {user.role === 'pending' &&
+																'bg-gray-600 dark:bg-gray-300'}"
+														/>
+														{$i18n.t(user.role)}</button
+													>
+												</td>
+												<td class="px-3 py-2 font-medium text-gray-900 dark:text-white w-max">
+													<div class="flex flex-row w-max">
+														<img
+															class=" rounded-full w-6 h-6 object-cover mr-2.5"
+															src={user.profile_image_url}
+															alt="user"
+														/>
+
+														<div class=" font-medium self-center">{user.name}</div>
+													</div>
+												</td>
+												<td class=" px-3 py-2"> {user.email} </td>
+
+												<td class=" px-3 py-2">
+													{dayjs(user.timestamp * 1000).format($i18n.t('MMMM DD, YYYY'))}
+												</td>
+
+												<td class="px-3 py-2 text-right">
+													<div class="flex justify-end w-full">
+														<button
+															class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+															on:click={async () => {
+																showEditUserModal = !showEditUserModal;
+																selectedUser = user;
+															}}
 														>
-															<path
-																stroke-linecap="round"
-																stroke-linejoin="round"
-																d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
-															/>
-														</svg>
-													</button>
+															<svg
+																xmlns="http://www.w3.org/2000/svg"
+																fill="none"
+																viewBox="0 0 24 24"
+																stroke-width="1.5"
+																stroke="currentColor"
+																class="w-4 h-4"
+															>
+																<path
+																	stroke-linecap="round"
+																	stroke-linejoin="round"
+																	d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
+																/>
+															</svg>
+														</button>
 
-													<button
-														class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
-														on:click={async () => {
-															deleteUserHandler(user.id);
-														}}
-													>
-														<svg
-															xmlns="http://www.w3.org/2000/svg"
-															fill="none"
-															viewBox="0 0 24 24"
-															stroke-width="1.5"
-															stroke="currentColor"
-															class="w-4 h-4"
+														<button
+															class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+															on:click={async () => {
+																deleteUserHandler(user.id);
+															}}
 														>
-															<path
-																stroke-linecap="round"
-																stroke-linejoin="round"
-																d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
-															/>
-														</svg>
-													</button>
-												</div>
-											</td>
-										</tr>
-									{/each}
-								</tbody>
-							</table>
+															<svg
+																xmlns="http://www.w3.org/2000/svg"
+																fill="none"
+																viewBox="0 0 24 24"
+																stroke-width="1.5"
+																stroke="currentColor"
+																class="w-4 h-4"
+															>
+																<path
+																	stroke-linecap="round"
+																	stroke-linejoin="round"
+																	d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
+																/>
+															</svg>
+														</button>
+													</div>
+												</td>
+											</tr>
+										{/each}
+									</tbody>
+								</table>
+							</div>
+
+							<div class=" text-gray-500 text-xs mt-2 text-right">
+								ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
+							</div>
 						</div>
 					</div>
 				</div>

+ 7 - 4
src/routes/auth/+page.svelte

@@ -6,6 +6,7 @@
 	import { WEBUI_NAME, config, user } from '$lib/stores';
 	import { onMount, getContext } from 'svelte';
 	import { toast } from 'svelte-sonner';
+	import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
 
 	const i18n = getContext('i18n');
 
@@ -36,10 +37,12 @@
 	};
 
 	const signUpHandler = async () => {
-		const sessionUser = await userSignUp(name, email, password).catch((error) => {
-			toast.error(error);
-			return null;
-		});
+		const sessionUser = await userSignUp(name, email, password, generateInitialsImage(name)).catch(
+			(error) => {
+				toast.error(error);
+				return null;
+			}
+		);
 
 		await setSessionUser(sessionUser);
 	};