github-actions[bot] commited on
Commit
8b50706
0 Parent(s):

GitHub deploy: ae0c6c6c16ac02732fda24c616dd98aafc529e97

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +18 -0
  2. .env.example +17 -0
  3. .eslintignore +13 -0
  4. .eslintrc.cjs +31 -0
  5. .gitattributes +2 -0
  6. .github/FUNDING.yml +1 -0
  7. .github/ISSUE_TEMPLATE/bug_report.md +63 -0
  8. .github/ISSUE_TEMPLATE/feature_request.md +19 -0
  9. .github/dependabot.yml +11 -0
  10. .github/pull_request_template.md +72 -0
  11. .github/workflows/build-release.yml +70 -0
  12. .github/workflows/deploy-to-hf-spaces.yml +48 -0
  13. .github/workflows/docker-build.yaml +414 -0
  14. .github/workflows/format-backend.yaml +39 -0
  15. .github/workflows/format-build-frontend.yaml +57 -0
  16. .github/workflows/integration-test.yml +195 -0
  17. .github/workflows/lint-backend.disabled +27 -0
  18. .github/workflows/lint-frontend.disabled +21 -0
  19. .github/workflows/sync-hf-spaces-with-dev.yml +31 -0
  20. .gitignore +308 -0
  21. .npmrc +1 -0
  22. .prettierignore +316 -0
  23. .prettierrc +9 -0
  24. CHANGELOG.md +424 -0
  25. Caddyfile.localhost +64 -0
  26. Dockerfile +163 -0
  27. INSTALLATION.md +35 -0
  28. LICENSE +21 -0
  29. Makefile +33 -0
  30. README.md +253 -0
  31. TROUBLESHOOTING.md +32 -0
  32. backend/.dockerignore +14 -0
  33. backend/.gitignore +16 -0
  34. backend/apps/audio/main.py +226 -0
  35. backend/apps/images/main.py +527 -0
  36. backend/apps/images/utils/comfyui.py +234 -0
  37. backend/apps/litellm/main.py +379 -0
  38. backend/apps/ollama/main.py +1420 -0
  39. backend/apps/openai/main.py +386 -0
  40. backend/apps/rag/main.py +971 -0
  41. backend/apps/rag/utils.py +522 -0
  42. backend/apps/web/internal/db.py +23 -0
  43. backend/apps/web/internal/migrations/001_initial_schema.py +254 -0
  44. backend/apps/web/internal/migrations/002_add_local_sharing.py +48 -0
  45. backend/apps/web/internal/migrations/003_add_auth_api_key.py +48 -0
  46. backend/apps/web/internal/migrations/004_add_archived.py +46 -0
  47. backend/apps/web/internal/migrations/005_add_updated_at.py +130 -0
  48. backend/apps/web/internal/migrations/006_migrate_timestamps_and_charfields.py +130 -0
  49. backend/apps/web/internal/migrations/007_add_user_last_active_at.py +79 -0
  50. backend/apps/web/internal/migrations/008_add_memory.py +53 -0
.dockerignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .github
2
+ .DS_Store
3
+ docs
4
+ kubernetes
5
+ node_modules
6
+ /.svelte-kit
7
+ /package
8
+ .env
9
+ .env.*
10
+ vite.config.js.timestamp-*
11
+ vite.config.ts.timestamp-*
12
+ __pycache__
13
+ .env
14
+ _old
15
+ uploads
16
+ .ipynb_checkpoints
17
+ **/*.db
18
+ _test
.env.example ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ollama URL for the backend to connect
2
+ # The path '/ollama' will be redirected to the specified backend URL
3
+ OLLAMA_BASE_URL='http://localhost:11434'
4
+
5
+ OPENAI_API_BASE_URL=''
6
+ OPENAI_API_KEY=''
7
+
8
+ # AUTOMATIC1111_BASE_URL="http://localhost:7860"
9
+
10
+ # DO NOT TRACK
11
+ SCARF_NO_ANALYTICS=true
12
+ DO_NOT_TRACK=true
13
+ ANONYMIZED_TELEMETRY=false
14
+
15
+ # Use locally bundled version of the LiteLLM cost map json
16
+ # to avoid repetitive startup connections
17
+ LITELLM_LOCAL_MODEL_COST_MAP="True"
.eslintignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ node_modules
3
+ /build
4
+ /.svelte-kit
5
+ /package
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+
10
+ # Ignore files for PNPM, NPM and YARN
11
+ pnpm-lock.yaml
12
+ package-lock.json
13
+ yarn.lock
.eslintrc.cjs ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ root: true,
3
+ extends: [
4
+ 'eslint:recommended',
5
+ 'plugin:@typescript-eslint/recommended',
6
+ 'plugin:svelte/recommended',
7
+ 'plugin:cypress/recommended',
8
+ 'prettier'
9
+ ],
10
+ parser: '@typescript-eslint/parser',
11
+ plugins: ['@typescript-eslint'],
12
+ parserOptions: {
13
+ sourceType: 'module',
14
+ ecmaVersion: 2020,
15
+ extraFileExtensions: ['.svelte']
16
+ },
17
+ env: {
18
+ browser: true,
19
+ es2017: true,
20
+ node: true
21
+ },
22
+ overrides: [
23
+ {
24
+ files: ['*.svelte'],
25
+ parser: 'svelte-eslint-parser',
26
+ parserOptions: {
27
+ parser: '@typescript-eslint/parser'
28
+ }
29
+ }
30
+ ]
31
+ };
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.sh text eol=lf
2
+ *.ttf filter=lfs diff=lfs merge=lfs -text
.github/FUNDING.yml ADDED
@@ -0,0 +1 @@
 
 
1
+ github: tjbck
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug report
3
+ about: Create a report to help us improve
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+ ---
8
+
9
+ # Bug Report
10
+
11
+ ## Description
12
+
13
+ **Bug Summary:**
14
+ [Provide a brief but clear summary of the bug]
15
+
16
+ **Steps to Reproduce:**
17
+ [Outline the steps to reproduce the bug. Be as detailed as possible.]
18
+
19
+ **Expected Behavior:**
20
+ [Describe what you expected to happen.]
21
+
22
+ **Actual Behavior:**
23
+ [Describe what actually happened.]
24
+
25
+ ## Environment
26
+
27
+ - **Open WebUI Version:** [e.g., 0.1.120]
28
+ - **Ollama (if applicable):** [e.g., 0.1.30, 0.1.32-rc1]
29
+
30
+ - **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04]
31
+ - **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0]
32
+
33
+ ## Reproduction Details
34
+
35
+ **Confirmation:**
36
+
37
+ - [ ] I have read and followed all the instructions provided in the README.md.
38
+ - [ ] I am on the latest version of both Open WebUI and Ollama.
39
+ - [ ] I have included the browser console logs.
40
+ - [ ] I have included the Docker container logs.
41
+
42
+ ## Logs and Screenshots
43
+
44
+ **Browser Console Logs:**
45
+ [Include relevant browser console logs, if applicable]
46
+
47
+ **Docker Container Logs:**
48
+ [Include relevant Docker container logs, if applicable]
49
+
50
+ **Screenshots (if applicable):**
51
+ [Attach any relevant screenshots to help illustrate the issue]
52
+
53
+ ## Installation Method
54
+
55
+ [Describe the method you used to install the project, e.g., manual installation, Docker, package manager, etc.]
56
+
57
+ ## Additional Information
58
+
59
+ [Include any additional details that may help in understanding and reproducing the issue. This could include specific configurations, error messages, or anything else relevant to the bug.]
60
+
61
+ ## Note
62
+
63
+ If the bug report is incomplete or does not follow the provided instructions, it may not be addressed. Please ensure that you have followed the steps outlined in the README.md and troubleshooting.md documents, and provide all necessary information for us to reproduce and address the issue. Thank you!
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature request
3
+ about: Suggest an idea for this project
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+ ---
8
+
9
+ **Is your feature request related to a problem? Please describe.**
10
+ A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11
+
12
+ **Describe the solution you'd like**
13
+ A clear and concise description of what you want to happen.
14
+
15
+ **Describe alternatives you've considered**
16
+ A clear and concise description of any alternative solutions or features you've considered.
17
+
18
+ **Additional context**
19
+ Add any other context or screenshots about the feature request here.
.github/dependabot.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: '/backend'
5
+ schedule:
6
+ interval: weekly
7
+ - package-ecosystem: 'github-actions'
8
+ directory: '/'
9
+ schedule:
10
+ # Check for updates to GitHub Actions every week
11
+ interval: 'weekly'
.github/pull_request_template.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pull Request Checklist
2
+
3
+ ### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) and describe your changes before submitting a pull request.
4
+
5
+ **Before submitting, make sure you've checked the following:**
6
+
7
+ - [ ] **Target branch:** Please verify that the pull request targets the `dev` branch.
8
+ - [ ] **Description:** Provide a concise description of the changes made in this pull request.
9
+ - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
10
+ - [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources?
11
+ - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
12
+ - [ ] **Testing:** Have you written and run sufficient tests for validating the changes?
13
+ - [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
14
+ - [ ] **Label:** To cleary categorize this pull request, assign a relevant label to the pull request title, using one of the following:
15
+ - **BREAKING CHANGE**: Significant changes that may affect compatibility
16
+ - **build**: Changes that affect the build system or external dependencies
17
+ - **ci**: Changes to our continuous integration processes or workflows
18
+ - **chore**: Refactor, cleanup, or other non-functional code changes
19
+ - **docs**: Documentation update or addition
20
+ - **feat**: Introduces a new feature or enhancement to the codebase
21
+ - **fix**: Bug fix or error correction
22
+ - **i18n**: Internationalization or localization changes
23
+ - **perf**: Performance improvement
24
+ - **refactor**: Code restructuring for better maintainability, readability, or scalability
25
+ - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.)
26
+ - **test**: Adding missing tests or correcting existing tests
27
+ - **WIP**: Work in progress, a temporary label for incomplete or ongoing work
28
+
29
+ # Changelog Entry
30
+
31
+ ### Description
32
+
33
+ - [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
34
+
35
+ ### Added
36
+
37
+ - [List any new features, functionalities, or additions]
38
+
39
+ ### Changed
40
+
41
+ - [List any changes, updates, refactorings, or optimizations]
42
+
43
+ ### Deprecated
44
+
45
+ - [List any deprecated functionality or features that have been removed]
46
+
47
+ ### Removed
48
+
49
+ - [List any removed features, files, or functionalities]
50
+
51
+ ### Fixed
52
+
53
+ - [List any fixes, corrections, or bug fixes]
54
+
55
+ ### Security
56
+
57
+ - [List any new or updated security-related changes, including vulnerability fixes]
58
+
59
+ ### Breaking Changes
60
+
61
+ - **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
62
+
63
+ ---
64
+
65
+ ### Additional Information
66
+
67
+ - [Insert any additional context, notes, or explanations for the changes]
68
+ - [Reference any related issues, commits, or other relevant information]
69
+
70
+ ### Screenshots or Videos
71
+
72
+ - [Attach any relevant screenshots or videos demonstrating the changes]
.github/workflows/build-release.yml ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main # or whatever branch you want to use
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v2
15
+
16
+ - name: Check for changes in package.json
17
+ run: |
18
+ git diff --cached --diff-filter=d package.json || {
19
+ echo "No changes to package.json"
20
+ exit 1
21
+ }
22
+
23
+ - name: Get version number from package.json
24
+ id: get_version
25
+ run: |
26
+ VERSION=$(jq -r '.version' package.json)
27
+ echo "::set-output name=version::$VERSION"
28
+
29
+ - name: Extract latest CHANGELOG entry
30
+ id: changelog
31
+ run: |
32
+ CHANGELOG_CONTENT=$(awk 'BEGIN {print_section=0;} /^## \[/ {if (print_section == 0) {print_section=1;} else {exit;}} print_section {print;}' CHANGELOG.md)
33
+ CHANGELOG_ESCAPED=$(echo "$CHANGELOG_CONTENT" | sed ':a;N;$!ba;s/\n/%0A/g')
34
+ echo "Extracted latest release notes from CHANGELOG.md:"
35
+ echo -e "$CHANGELOG_CONTENT"
36
+ echo "::set-output name=content::$CHANGELOG_ESCAPED"
37
+
38
+ - name: Create GitHub release
39
+ uses: actions/github-script@v5
40
+ with:
41
+ github-token: ${{ secrets.GITHUB_TOKEN }}
42
+ script: |
43
+ const changelog = `${{ steps.changelog.outputs.content }}`;
44
+ const release = await github.rest.repos.createRelease({
45
+ owner: context.repo.owner,
46
+ repo: context.repo.repo,
47
+ tag_name: `v${{ steps.get_version.outputs.version }}`,
48
+ name: `v${{ steps.get_version.outputs.version }}`,
49
+ body: changelog,
50
+ })
51
+ console.log(`Created release ${release.data.html_url}`)
52
+
53
+ - name: Upload package to GitHub release
54
+ uses: actions/upload-artifact@v3
55
+ with:
56
+ name: package
57
+ path: .
58
+ env:
59
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
60
+
61
+ - name: Trigger Docker build workflow
62
+ uses: actions/github-script@v7
63
+ with:
64
+ script: |
65
+ github.rest.actions.createWorkflowDispatch({
66
+ owner: context.repo.owner,
67
+ repo: context.repo.repo,
68
+ workflow_id: 'docker-build.yaml',
69
+ ref: 'v${{ steps.get_version.outputs.version }}',
70
+ })
.github/workflows/deploy-to-hf-spaces.yml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to HuggingFace Spaces
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - hf-space
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ check-secret:
11
+ runs-on: ubuntu-latest
12
+ outputs:
13
+ token-set: ${{ steps.check-key.outputs.defined }}
14
+ steps:
15
+ - id: check-key
16
+ env:
17
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
18
+ if: "${{ env.HF_TOKEN != '' }}"
19
+ run: echo "defined=true" >> $GITHUB_OUTPUT
20
+
21
+ deploy:
22
+ runs-on: ubuntu-latest
23
+ needs: [check-secret]
24
+ if: needs.check-secret.outputs.token-set == 'true'
25
+ env:
26
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
27
+ HF_USERNAME: ${{ secrets.HF_USERNAME }}
28
+ HF_SPACE_NAME: ${{ secrets.HF_SPACE_NAME }}
29
+ steps:
30
+ - name: Checkout repository
31
+ uses: actions/checkout@v4
32
+
33
+ - name: Remove git history
34
+ run: rm -rf .git
35
+
36
+ - name: Configure git
37
+ run: |
38
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
39
+ git config --global user.name "github-actions[bot]"
40
+
41
+ - name: Set up Git and push to Space
42
+ run: |
43
+ git init --initial-branch=main
44
+ git lfs track "*.ttf"
45
+ rm demo.gif
46
+ git add .
47
+ git commit -m "GitHub deploy: ${{ github.sha }}"
48
+ git push --force https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${HF_USERNAME}/${HF_SPACE_NAME} main
.github/workflows/docker-build.yaml ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Create and publish Docker images with specific build args
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches:
7
+ - main
8
+ - dev
9
+ tags:
10
+ - v*
11
+
12
+ env:
13
+ REGISTRY: ghcr.io
14
+ IMAGE_NAME: ${{ github.repository }}
15
+ FULL_IMAGE_NAME: ghcr.io/${{ github.repository }}
16
+
17
+ jobs:
18
+ build-main-image:
19
+ runs-on: ubuntu-latest
20
+ permissions:
21
+ contents: read
22
+ packages: write
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ platform:
27
+ - linux/amd64
28
+ - linux/arm64
29
+
30
+ steps:
31
+ - name: Prepare
32
+ run: |
33
+ platform=${{ matrix.platform }}
34
+ echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
35
+
36
+ - name: Checkout repository
37
+ uses: actions/checkout@v4
38
+
39
+ - name: Set up QEMU
40
+ uses: docker/setup-qemu-action@v3
41
+
42
+ - name: Set up Docker Buildx
43
+ uses: docker/setup-buildx-action@v3
44
+
45
+ - name: Log in to the Container registry
46
+ uses: docker/login-action@v3
47
+ with:
48
+ registry: ${{ env.REGISTRY }}
49
+ username: ${{ github.actor }}
50
+ password: ${{ secrets.GITHUB_TOKEN }}
51
+
52
+ - name: Extract metadata for Docker images (default latest tag)
53
+ id: meta
54
+ uses: docker/metadata-action@v5
55
+ with:
56
+ images: ${{ env.FULL_IMAGE_NAME }}
57
+ tags: |
58
+ type=ref,event=branch
59
+ type=ref,event=tag
60
+ type=sha,prefix=git-
61
+ type=semver,pattern={{version}}
62
+ type=semver,pattern={{major}}.{{minor}}
63
+ flavor: |
64
+ latest=${{ github.ref == 'refs/heads/main' }}
65
+
66
+ - name: Extract metadata for Docker cache
67
+ id: cache-meta
68
+ uses: docker/metadata-action@v5
69
+ with:
70
+ images: ${{ env.FULL_IMAGE_NAME }}
71
+ tags: |
72
+ type=ref,event=branch
73
+ flavor: |
74
+ prefix=cache-${{ matrix.platform }}-
75
+
76
+ - name: Build Docker image (latest)
77
+ uses: docker/build-push-action@v5
78
+ id: build
79
+ with:
80
+ context: .
81
+ push: true
82
+ platforms: ${{ matrix.platform }}
83
+ labels: ${{ steps.meta.outputs.labels }}
84
+ outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
85
+ cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
86
+ cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
87
+
88
+ - name: Export digest
89
+ run: |
90
+ mkdir -p /tmp/digests
91
+ digest="${{ steps.build.outputs.digest }}"
92
+ touch "/tmp/digests/${digest#sha256:}"
93
+
94
+ - name: Upload digest
95
+ uses: actions/upload-artifact@v4
96
+ with:
97
+ name: digests-main-${{ env.PLATFORM_PAIR }}
98
+ path: /tmp/digests/*
99
+ if-no-files-found: error
100
+ retention-days: 1
101
+
102
+ build-cuda-image:
103
+ runs-on: ubuntu-latest
104
+ permissions:
105
+ contents: read
106
+ packages: write
107
+ strategy:
108
+ fail-fast: false
109
+ matrix:
110
+ platform:
111
+ - linux/amd64
112
+ - linux/arm64
113
+
114
+ steps:
115
+ - name: Prepare
116
+ run: |
117
+ platform=${{ matrix.platform }}
118
+ echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
119
+
120
+ - name: Checkout repository
121
+ uses: actions/checkout@v4
122
+
123
+ - name: Set up QEMU
124
+ uses: docker/setup-qemu-action@v3
125
+
126
+ - name: Set up Docker Buildx
127
+ uses: docker/setup-buildx-action@v3
128
+
129
+ - name: Log in to the Container registry
130
+ uses: docker/login-action@v3
131
+ with:
132
+ registry: ${{ env.REGISTRY }}
133
+ username: ${{ github.actor }}
134
+ password: ${{ secrets.GITHUB_TOKEN }}
135
+
136
+ - name: Extract metadata for Docker images (cuda tag)
137
+ id: meta
138
+ uses: docker/metadata-action@v5
139
+ with:
140
+ images: ${{ env.FULL_IMAGE_NAME }}
141
+ tags: |
142
+ type=ref,event=branch
143
+ type=ref,event=tag
144
+ type=sha,prefix=git-
145
+ type=semver,pattern={{version}}
146
+ type=semver,pattern={{major}}.{{minor}}
147
+ type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
148
+ flavor: |
149
+ latest=${{ github.ref == 'refs/heads/main' }}
150
+ suffix=-cuda,onlatest=true
151
+
152
+ - name: Extract metadata for Docker cache
153
+ id: cache-meta
154
+ uses: docker/metadata-action@v5
155
+ with:
156
+ images: ${{ env.FULL_IMAGE_NAME }}
157
+ tags: |
158
+ type=ref,event=branch
159
+ flavor: |
160
+ prefix=cache-cuda-${{ matrix.platform }}-
161
+
162
+ - name: Build Docker image (cuda)
163
+ uses: docker/build-push-action@v5
164
+ id: build
165
+ with:
166
+ context: .
167
+ push: true
168
+ platforms: ${{ matrix.platform }}
169
+ labels: ${{ steps.meta.outputs.labels }}
170
+ outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
171
+ cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
172
+ cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
173
+ build-args: USE_CUDA=true
174
+
175
+ - name: Export digest
176
+ run: |
177
+ mkdir -p /tmp/digests
178
+ digest="${{ steps.build.outputs.digest }}"
179
+ touch "/tmp/digests/${digest#sha256:}"
180
+
181
+ - name: Upload digest
182
+ uses: actions/upload-artifact@v4
183
+ with:
184
+ name: digests-cuda-${{ env.PLATFORM_PAIR }}
185
+ path: /tmp/digests/*
186
+ if-no-files-found: error
187
+ retention-days: 1
188
+
189
+ build-ollama-image:
190
+ runs-on: ubuntu-latest
191
+ permissions:
192
+ contents: read
193
+ packages: write
194
+ strategy:
195
+ fail-fast: false
196
+ matrix:
197
+ platform:
198
+ - linux/amd64
199
+ - linux/arm64
200
+
201
+ steps:
202
+ - name: Prepare
203
+ run: |
204
+ platform=${{ matrix.platform }}
205
+ echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
206
+
207
+ - name: Checkout repository
208
+ uses: actions/checkout@v4
209
+
210
+ - name: Set up QEMU
211
+ uses: docker/setup-qemu-action@v3
212
+
213
+ - name: Set up Docker Buildx
214
+ uses: docker/setup-buildx-action@v3
215
+
216
+ - name: Log in to the Container registry
217
+ uses: docker/login-action@v3
218
+ with:
219
+ registry: ${{ env.REGISTRY }}
220
+ username: ${{ github.actor }}
221
+ password: ${{ secrets.GITHUB_TOKEN }}
222
+
223
+ - name: Extract metadata for Docker images (ollama tag)
224
+ id: meta
225
+ uses: docker/metadata-action@v5
226
+ with:
227
+ images: ${{ env.FULL_IMAGE_NAME }}
228
+ tags: |
229
+ type=ref,event=branch
230
+ type=ref,event=tag
231
+ type=sha,prefix=git-
232
+ type=semver,pattern={{version}}
233
+ type=semver,pattern={{major}}.{{minor}}
234
+ type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
235
+ flavor: |
236
+ latest=${{ github.ref == 'refs/heads/main' }}
237
+ suffix=-ollama,onlatest=true
238
+
239
+ - name: Extract metadata for Docker cache
240
+ id: cache-meta
241
+ uses: docker/metadata-action@v5
242
+ with:
243
+ images: ${{ env.FULL_IMAGE_NAME }}
244
+ tags: |
245
+ type=ref,event=branch
246
+ flavor: |
247
+ prefix=cache-ollama-${{ matrix.platform }}-
248
+
249
+ - name: Build Docker image (ollama)
250
+ uses: docker/build-push-action@v5
251
+ id: build
252
+ with:
253
+ context: .
254
+ push: true
255
+ platforms: ${{ matrix.platform }}
256
+ labels: ${{ steps.meta.outputs.labels }}
257
+ outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
258
+ cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
259
+ cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
260
+ build-args: USE_OLLAMA=true
261
+
262
+ - name: Export digest
263
+ run: |
264
+ mkdir -p /tmp/digests
265
+ digest="${{ steps.build.outputs.digest }}"
266
+ touch "/tmp/digests/${digest#sha256:}"
267
+
268
+ - name: Upload digest
269
+ uses: actions/upload-artifact@v4
270
+ with:
271
+ name: digests-ollama-${{ env.PLATFORM_PAIR }}
272
+ path: /tmp/digests/*
273
+ if-no-files-found: error
274
+ retention-days: 1
275
+
276
+ merge-main-images:
277
+ runs-on: ubuntu-latest
278
+ needs: [ build-main-image ]
279
+ steps:
280
+ - name: Download digests
281
+ uses: actions/download-artifact@v4
282
+ with:
283
+ pattern: digests-main-*
284
+ path: /tmp/digests
285
+ merge-multiple: true
286
+
287
+ - name: Set up Docker Buildx
288
+ uses: docker/setup-buildx-action@v3
289
+
290
+ - name: Log in to the Container registry
291
+ uses: docker/login-action@v3
292
+ with:
293
+ registry: ${{ env.REGISTRY }}
294
+ username: ${{ github.actor }}
295
+ password: ${{ secrets.GITHUB_TOKEN }}
296
+
297
+ - name: Extract metadata for Docker images (default latest tag)
298
+ id: meta
299
+ uses: docker/metadata-action@v5
300
+ with:
301
+ images: ${{ env.FULL_IMAGE_NAME }}
302
+ tags: |
303
+ type=ref,event=branch
304
+ type=ref,event=tag
305
+ type=sha,prefix=git-
306
+ type=semver,pattern={{version}}
307
+ type=semver,pattern={{major}}.{{minor}}
308
+ flavor: |
309
+ latest=${{ github.ref == 'refs/heads/main' }}
310
+
311
+ - name: Create manifest list and push
312
+ working-directory: /tmp/digests
313
+ run: |
314
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
315
+ $(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
316
+
317
+ - name: Inspect image
318
+ run: |
319
+ docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
320
+
321
+
322
+ merge-cuda-images:
323
+ runs-on: ubuntu-latest
324
+ needs: [ build-cuda-image ]
325
+ steps:
326
+ - name: Download digests
327
+ uses: actions/download-artifact@v4
328
+ with:
329
+ pattern: digests-cuda-*
330
+ path: /tmp/digests
331
+ merge-multiple: true
332
+
333
+ - name: Set up Docker Buildx
334
+ uses: docker/setup-buildx-action@v3
335
+
336
+ - name: Log in to the Container registry
337
+ uses: docker/login-action@v3
338
+ with:
339
+ registry: ${{ env.REGISTRY }}
340
+ username: ${{ github.actor }}
341
+ password: ${{ secrets.GITHUB_TOKEN }}
342
+
343
+ - name: Extract metadata for Docker images (default latest tag)
344
+ id: meta
345
+ uses: docker/metadata-action@v5
346
+ with:
347
+ images: ${{ env.FULL_IMAGE_NAME }}
348
+ tags: |
349
+ type=ref,event=branch
350
+ type=ref,event=tag
351
+ type=sha,prefix=git-
352
+ type=semver,pattern={{version}}
353
+ type=semver,pattern={{major}}.{{minor}}
354
+ type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
355
+ flavor: |
356
+ latest=${{ github.ref == 'refs/heads/main' }}
357
+ suffix=-cuda,onlatest=true
358
+
359
+ - name: Create manifest list and push
360
+ working-directory: /tmp/digests
361
+ run: |
362
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
363
+ $(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
364
+
365
+ - name: Inspect image
366
+ run: |
367
+ docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
368
+
369
+ merge-ollama-images:
370
+ runs-on: ubuntu-latest
371
+ needs: [ build-ollama-image ]
372
+ steps:
373
+ - name: Download digests
374
+ uses: actions/download-artifact@v4
375
+ with:
376
+ pattern: digests-ollama-*
377
+ path: /tmp/digests
378
+ merge-multiple: true
379
+
380
+ - name: Set up Docker Buildx
381
+ uses: docker/setup-buildx-action@v3
382
+
383
+ - name: Log in to the Container registry
384
+ uses: docker/login-action@v3
385
+ with:
386
+ registry: ${{ env.REGISTRY }}
387
+ username: ${{ github.actor }}
388
+ password: ${{ secrets.GITHUB_TOKEN }}
389
+
390
+ - name: Extract metadata for Docker images (default ollama tag)
391
+ id: meta
392
+ uses: docker/metadata-action@v5
393
+ with:
394
+ images: ${{ env.FULL_IMAGE_NAME }}
395
+ tags: |
396
+ type=ref,event=branch
397
+ type=ref,event=tag
398
+ type=sha,prefix=git-
399
+ type=semver,pattern={{version}}
400
+ type=semver,pattern={{major}}.{{minor}}
401
+ type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
402
+ flavor: |
403
+ latest=${{ github.ref == 'refs/heads/main' }}
404
+ suffix=-ollama,onlatest=true
405
+
406
+ - name: Create manifest list and push
407
+ working-directory: /tmp/digests
408
+ run: |
409
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
410
+ $(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
411
+
412
+ - name: Inspect image
413
+ run: |
414
+ docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
.github/workflows/format-backend.yaml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Python CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - dev
8
+ pull_request:
9
+ branches:
10
+ - main
11
+ - dev
12
+
13
+ jobs:
14
+ build:
15
+ name: 'Format Backend'
16
+ runs-on: ubuntu-latest
17
+
18
+ strategy:
19
+ matrix:
20
+ python-version: [3.11]
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v2
27
+ with:
28
+ python-version: ${{ matrix.python-version }}
29
+
30
+ - name: Install dependencies
31
+ run: |
32
+ python -m pip install --upgrade pip
33
+ pip install black
34
+
35
+ - name: Format backend
36
+ run: npm run format:backend
37
+
38
+ - name: Check for changes after format
39
+ run: git diff --exit-code
.github/workflows/format-build-frontend.yaml ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Frontend Build
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - dev
8
+ pull_request:
9
+ branches:
10
+ - main
11
+ - dev
12
+
13
+ jobs:
14
+ build:
15
+ name: 'Format & Build Frontend'
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - name: Checkout Repository
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Setup Node.js
22
+ uses: actions/setup-node@v3
23
+ with:
24
+ node-version: '20' # Or specify any other version you want to use
25
+
26
+ - name: Install Dependencies
27
+ run: npm install
28
+
29
+ - name: Format Frontend
30
+ run: npm run format
31
+
32
+ - name: Run i18next
33
+ run: npm run i18n:parse
34
+
35
+ - name: Check for Changes After Format
36
+ run: git diff --exit-code
37
+
38
+ - name: Build Frontend
39
+ run: npm run build
40
+
41
+ test-frontend:
42
+ name: 'Frontend Unit Tests'
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - name: Checkout Repository
46
+ uses: actions/checkout@v4
47
+
48
+ - name: Setup Node.js
49
+ uses: actions/setup-node@v4
50
+ with:
51
+ node-version: '20'
52
+
53
+ - name: Install Dependencies
54
+ run: npm ci
55
+
56
+ - name: Run vitest
57
+ run: npm run test:frontend
.github/workflows/integration-test.yml ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Integration Test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - dev
8
+ pull_request:
9
+ branches:
10
+ - main
11
+ - dev
12
+
13
+ jobs:
14
+ cypress-run:
15
+ name: Run Cypress Integration Tests
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - name: Checkout Repository
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Build and run Compose Stack
22
+ run: |
23
+ docker compose --file docker-compose.yaml --file docker-compose.api.yaml up --detach --build
24
+
25
+ - name: Wait for Ollama to be up
26
+ timeout-minutes: 5
27
+ run: |
28
+ until curl --output /dev/null --silent --fail http://localhost:11434; do
29
+ printf '.'
30
+ sleep 1
31
+ done
32
+ echo "Service is up!"
33
+
34
+ - name: Preload Ollama model
35
+ run: |
36
+ docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K
37
+
38
+ - name: Cypress run
39
+ uses: cypress-io/github-action@v6
40
+ with:
41
+ browser: chrome
42
+ wait-on: 'http://localhost:3000'
43
+ config: baseUrl=http://localhost:3000
44
+
45
+ - uses: actions/upload-artifact@v4
46
+ if: always()
47
+ name: Upload Cypress videos
48
+ with:
49
+ name: cypress-videos
50
+ path: cypress/videos
51
+ if-no-files-found: ignore
52
+
53
+ - name: Extract Compose logs
54
+ if: always()
55
+ run: |
56
+ docker compose logs > compose-logs.txt
57
+
58
+ - uses: actions/upload-artifact@v4
59
+ if: always()
60
+ name: Upload Compose logs
61
+ with:
62
+ name: compose-logs
63
+ path: compose-logs.txt
64
+ if-no-files-found: ignore
65
+
66
+ migration_test:
67
+ name: Run Migration Tests
68
+ runs-on: ubuntu-latest
69
+ services:
70
+ postgres:
71
+ image: postgres
72
+ env:
73
+ POSTGRES_PASSWORD: postgres
74
+ options: >-
75
+ --health-cmd pg_isready
76
+ --health-interval 10s
77
+ --health-timeout 5s
78
+ --health-retries 5
79
+ ports:
80
+ - 5432:5432
81
+ # mysql:
82
+ # image: mysql
83
+ # env:
84
+ # MYSQL_ROOT_PASSWORD: mysql
85
+ # MYSQL_DATABASE: mysql
86
+ # options: >-
87
+ # --health-cmd "mysqladmin ping -h localhost"
88
+ # --health-interval 10s
89
+ # --health-timeout 5s
90
+ # --health-retries 5
91
+ # ports:
92
+ # - 3306:3306
93
+ steps:
94
+ - name: Checkout Repository
95
+ uses: actions/checkout@v4
96
+
97
+ - name: Set up Python
98
+ uses: actions/setup-python@v2
99
+ with:
100
+ python-version: ${{ matrix.python-version }}
101
+
102
+ - name: Set up uv
103
+ uses: yezz123/setup-uv@v4
104
+ with:
105
+ uv-venv: venv
106
+
107
+ - name: Activate virtualenv
108
+ run: |
109
+ . venv/bin/activate
110
+ echo PATH=$PATH >> $GITHUB_ENV
111
+
112
+ - name: Install dependencies
113
+ run: |
114
+ uv pip install -r backend/requirements.txt
115
+
116
+ - name: Test backend with SQLite
117
+ id: sqlite
118
+ env:
119
+ WEBUI_SECRET_KEY: secret-key
120
+ GLOBAL_LOG_LEVEL: debug
121
+ run: |
122
+ cd backend
123
+ uvicorn main:app --port "8080" --forwarded-allow-ips '*' &
124
+ UVICORN_PID=$!
125
+ # Wait up to 20 seconds for the server to start
126
+ for i in {1..20}; do
127
+ curl -s http://localhost:8080/api/config > /dev/null && break
128
+ sleep 1
129
+ if [ $i -eq 20 ]; then
130
+ echo "Server failed to start"
131
+ kill -9 $UVICORN_PID
132
+ exit 1
133
+ fi
134
+ done
135
+ # Check that the server is still running after 5 seconds
136
+ sleep 5
137
+ if ! kill -0 $UVICORN_PID; then
138
+ echo "Server has stopped"
139
+ exit 1
140
+ fi
141
+
142
+
143
+ - name: Test backend with Postgres
144
+ if: success() || steps.sqlite.conclusion == 'failure'
145
+ env:
146
+ WEBUI_SECRET_KEY: secret-key
147
+ GLOBAL_LOG_LEVEL: debug
148
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
149
+ run: |
150
+ cd backend
151
+ uvicorn main:app --port "8081" --forwarded-allow-ips '*' &
152
+ UVICORN_PID=$!
153
+ # Wait up to 20 seconds for the server to start
154
+ for i in {1..20}; do
155
+ curl -s http://localhost:8081/api/config > /dev/null && break
156
+ sleep 1
157
+ if [ $i -eq 20 ]; then
158
+ echo "Server failed to start"
159
+ kill -9 $UVICORN_PID
160
+ exit 1
161
+ fi
162
+ done
163
+ # Check that the server is still running after 5 seconds
164
+ sleep 5
165
+ if ! kill -0 $UVICORN_PID; then
166
+ echo "Server has stopped"
167
+ exit 1
168
+ fi
169
+
170
+ # - name: Test backend with MySQL
171
+ # if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure'
172
+ # env:
173
+ # WEBUI_SECRET_KEY: secret-key
174
+ # GLOBAL_LOG_LEVEL: debug
175
+ # DATABASE_URL: mysql://root:mysql@localhost:3306/mysql
176
+ # run: |
177
+ # cd backend
178
+ # uvicorn main:app --port "8083" --forwarded-allow-ips '*' &
179
+ # UVICORN_PID=$!
180
+ # # Wait up to 20 seconds for the server to start
181
+ # for i in {1..20}; do
182
+ # curl -s http://localhost:8083/api/config > /dev/null && break
183
+ # sleep 1
184
+ # if [ $i -eq 20 ]; then
185
+ # echo "Server failed to start"
186
+ # kill -9 $UVICORN_PID
187
+ # exit 1
188
+ # fi
189
+ # done
190
+ # # Check that the server is still running after 5 seconds
191
+ # sleep 5
192
+ # if ! kill -0 $UVICORN_PID; then
193
+ # echo "Server has stopped"
194
+ # exit 1
195
+ # fi
.github/workflows/lint-backend.disabled ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Python CI
2
+ on:
3
+ push:
4
+ branches: ['main']
5
+ pull_request:
6
+ jobs:
7
+ build:
8
+ name: 'Lint Backend'
9
+ env:
10
+ PUBLIC_API_BASE_URL: ''
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ node-version:
15
+ - latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Use Python
19
+ uses: actions/setup-python@v4
20
+ - name: Use Bun
21
+ uses: oven-sh/setup-bun@v1
22
+ - name: Install dependencies
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install pylint
26
+ - name: Lint backend
27
+ run: bun run lint:backend
.github/workflows/lint-frontend.disabled ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Bun CI
2
+ on:
3
+ push:
4
+ branches: ['main']
5
+ pull_request:
6
+ jobs:
7
+ build:
8
+ name: 'Lint Frontend'
9
+ env:
10
+ PUBLIC_API_BASE_URL: ''
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Use Bun
15
+ uses: oven-sh/setup-bun@v1
16
+ - run: bun --version
17
+ - name: Install frontend dependencies
18
+ run: bun install --frozen-lockfile
19
+ - run: bun run lint:frontend
20
+ - run: bun run lint:types
21
+ if: success() || failure()
.github/workflows/sync-hf-spaces-with-dev.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync hf-spaces with dev
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - dev
7
+ - hf-spaces
8
+ schedule:
9
+ - cron: '0 0 * * *'
10
+ pull_request:
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ sync:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: write
18
+ steps:
19
+ - name: Checkout repository
20
+ uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0
23
+
24
+ - name: Sync with dev
25
+ run: |
26
+ git checkout dev
27
+ git fetch origin
28
+ git checkout hf-space
29
+ git pull
30
+ git merge origin/dev
31
+ git push origin hf-space
.gitignore ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ node_modules
3
+ /build
4
+ /.svelte-kit
5
+ /package
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ vite.config.js.timestamp-*
10
+ vite.config.ts.timestamp-*
11
+ # Byte-compiled / optimized / DLL files
12
+ __pycache__/
13
+ *.py[cod]
14
+ *$py.class
15
+
16
+ # C extensions
17
+ *.so
18
+
19
+ # Pyodide distribution
20
+ static/pyodide/*
21
+ !static/pyodide/pyodide-lock.json
22
+
23
+ # Distribution / packaging
24
+ .Python
25
+ build/
26
+ develop-eggs/
27
+ dist/
28
+ downloads/
29
+ eggs/
30
+ .eggs/
31
+ lib64/
32
+ parts/
33
+ sdist/
34
+ var/
35
+ wheels/
36
+ share/python-wheels/
37
+ *.egg-info/
38
+ .installed.cfg
39
+ *.egg
40
+ MANIFEST
41
+
42
+ # PyInstaller
43
+ # Usually these files are written by a python script from a template
44
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
45
+ *.manifest
46
+ *.spec
47
+
48
+ # Installer logs
49
+ pip-log.txt
50
+ pip-delete-this-directory.txt
51
+
52
+ # Unit test / coverage reports
53
+ htmlcov/
54
+ .tox/
55
+ .nox/
56
+ .coverage
57
+ .coverage.*
58
+ .cache
59
+ nosetests.xml
60
+ coverage.xml
61
+ *.cover
62
+ *.py,cover
63
+ .hypothesis/
64
+ .pytest_cache/
65
+ cover/
66
+
67
+ # Translations
68
+ *.mo
69
+ *.pot
70
+
71
+ # Django stuff:
72
+ *.log
73
+ local_settings.py
74
+ db.sqlite3
75
+ db.sqlite3-journal
76
+
77
+ # Flask stuff:
78
+ instance/
79
+ .webassets-cache
80
+
81
+ # Scrapy stuff:
82
+ .scrapy
83
+
84
+ # Sphinx documentation
85
+ docs/_build/
86
+
87
+ # PyBuilder
88
+ .pybuilder/
89
+ target/
90
+
91
+ # Jupyter Notebook
92
+ .ipynb_checkpoints
93
+
94
+ # IPython
95
+ profile_default/
96
+ ipython_config.py
97
+
98
+ # pyenv
99
+ # For a library or package, you might want to ignore these files since the code is
100
+ # intended to run in multiple environments; otherwise, check them in:
101
+ # .python-version
102
+
103
+ # pipenv
104
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
105
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
106
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
107
+ # install all needed dependencies.
108
+ #Pipfile.lock
109
+
110
+ # poetry
111
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
112
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
113
+ # commonly ignored for libraries.
114
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
115
+ #poetry.lock
116
+
117
+ # pdm
118
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
119
+ #pdm.lock
120
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
121
+ # in version control.
122
+ # https://pdm.fming.dev/#use-with-ide
123
+ .pdm.toml
124
+
125
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
126
+ __pypackages__/
127
+
128
+ # Celery stuff
129
+ celerybeat-schedule
130
+ celerybeat.pid
131
+
132
+ # SageMath parsed files
133
+ *.sage.py
134
+
135
+ # Environments
136
+ .env
137
+ .venv
138
+ env/
139
+ venv/
140
+ ENV/
141
+ env.bak/
142
+ venv.bak/
143
+
144
+ # Spyder project settings
145
+ .spyderproject
146
+ .spyproject
147
+
148
+ # Rope project settings
149
+ .ropeproject
150
+
151
+ # mkdocs documentation
152
+ /site
153
+
154
+ # mypy
155
+ .mypy_cache/
156
+ .dmypy.json
157
+ dmypy.json
158
+
159
+ # Pyre type checker
160
+ .pyre/
161
+
162
+ # pytype static type analyzer
163
+ .pytype/
164
+
165
+ # Cython debug symbols
166
+ cython_debug/
167
+
168
+ # PyCharm
169
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
170
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
171
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
172
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
173
+ .idea/
174
+
175
+ # Logs
176
+ logs
177
+ *.log
178
+ npm-debug.log*
179
+ yarn-debug.log*
180
+ yarn-error.log*
181
+ lerna-debug.log*
182
+ .pnpm-debug.log*
183
+
184
+ # Diagnostic reports (https://nodejs.org/api/report.html)
185
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
186
+
187
+ # Runtime data
188
+ pids
189
+ *.pid
190
+ *.seed
191
+ *.pid.lock
192
+
193
+ # Directory for instrumented libs generated by jscoverage/JSCover
194
+ lib-cov
195
+
196
+ # Coverage directory used by tools like istanbul
197
+ coverage
198
+ *.lcov
199
+
200
+ # nyc test coverage
201
+ .nyc_output
202
+
203
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
204
+ .grunt
205
+
206
+ # Bower dependency directory (https://bower.io/)
207
+ bower_components
208
+
209
+ # node-waf configuration
210
+ .lock-wscript
211
+
212
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
213
+ build/Release
214
+
215
+ # Dependency directories
216
+ node_modules/
217
+ jspm_packages/
218
+
219
+ # Snowpack dependency directory (https://snowpack.dev/)
220
+ web_modules/
221
+
222
+ # TypeScript cache
223
+ *.tsbuildinfo
224
+
225
+ # Optional npm cache directory
226
+ .npm
227
+
228
+ # Optional eslint cache
229
+ .eslintcache
230
+
231
+ # Optional stylelint cache
232
+ .stylelintcache
233
+
234
+ # Microbundle cache
235
+ .rpt2_cache/
236
+ .rts2_cache_cjs/
237
+ .rts2_cache_es/
238
+ .rts2_cache_umd/
239
+
240
+ # Optional REPL history
241
+ .node_repl_history
242
+
243
+ # Output of 'npm pack'
244
+ *.tgz
245
+
246
+ # Yarn Integrity file
247
+ .yarn-integrity
248
+
249
+ # dotenv environment variable files
250
+ .env
251
+ .env.development.local
252
+ .env.test.local
253
+ .env.production.local
254
+ .env.local
255
+
256
+ # parcel-bundler cache (https://parceljs.org/)
257
+ .cache
258
+ .parcel-cache
259
+
260
+ # Next.js build output
261
+ .next
262
+ out
263
+
264
+ # Nuxt.js build / generate output
265
+ .nuxt
266
+ dist
267
+
268
+ # Gatsby files
269
+ .cache/
270
+ # Comment in the public line in if your project uses Gatsby and not Next.js
271
+ # https://nextjs.org/blog/next-9-1#public-directory-support
272
+ # public
273
+
274
+ # vuepress build output
275
+ .vuepress/dist
276
+
277
+ # vuepress v2.x temp and cache directory
278
+ .temp
279
+ .cache
280
+
281
+ # Docusaurus cache and generated files
282
+ .docusaurus
283
+
284
+ # Serverless directories
285
+ .serverless/
286
+
287
+ # FuseBox cache
288
+ .fusebox/
289
+
290
+ # DynamoDB Local files
291
+ .dynamodb/
292
+
293
+ # TernJS port file
294
+ .tern-port
295
+
296
+ # Stores VSCode versions used for testing VSCode extensions
297
+ .vscode-test
298
+
299
+ # yarn v2
300
+ .yarn/cache
301
+ .yarn/unplugged
302
+ .yarn/build-state.yml
303
+ .yarn/install-state.gz
304
+ .pnp.*
305
+
306
+ # cypress artifacts
307
+ cypress/videos
308
+ cypress/screenshots
.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ engine-strict=true
.prettierignore ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ignore files for PNPM, NPM and YARN
2
+ pnpm-lock.yaml
3
+ package-lock.json
4
+ yarn.lock
5
+
6
+ kubernetes/
7
+
8
+ # Copy of .gitignore
9
+ .DS_Store
10
+ node_modules
11
+ /build
12
+ /.svelte-kit
13
+ /package
14
+ .env
15
+ .env.*
16
+ !.env.example
17
+ vite.config.js.timestamp-*
18
+ vite.config.ts.timestamp-*
19
+ # Byte-compiled / optimized / DLL files
20
+ __pycache__/
21
+ *.py[cod]
22
+ *$py.class
23
+
24
+ # C extensions
25
+ *.so
26
+
27
+ # Distribution / packaging
28
+ .Python
29
+ build/
30
+ develop-eggs/
31
+ dist/
32
+ downloads/
33
+ eggs/
34
+ .eggs/
35
+ lib64/
36
+ parts/
37
+ sdist/
38
+ var/
39
+ wheels/
40
+ share/python-wheels/
41
+ *.egg-info/
42
+ .installed.cfg
43
+ *.egg
44
+ MANIFEST
45
+
46
+ # PyInstaller
47
+ # Usually these files are written by a python script from a template
48
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
49
+ *.manifest
50
+ *.spec
51
+
52
+ # Installer logs
53
+ pip-log.txt
54
+ pip-delete-this-directory.txt
55
+
56
+ # Unit test / coverage reports
57
+ htmlcov/
58
+ .tox/
59
+ .nox/
60
+ .coverage
61
+ .coverage.*
62
+ .cache
63
+ nosetests.xml
64
+ coverage.xml
65
+ *.cover
66
+ *.py,cover
67
+ .hypothesis/
68
+ .pytest_cache/
69
+ cover/
70
+
71
+ # Translations
72
+ *.mo
73
+ *.pot
74
+
75
+ # Django stuff:
76
+ *.log
77
+ local_settings.py
78
+ db.sqlite3
79
+ db.sqlite3-journal
80
+
81
+ # Flask stuff:
82
+ instance/
83
+ .webassets-cache
84
+
85
+ # Scrapy stuff:
86
+ .scrapy
87
+
88
+ # Sphinx documentation
89
+ docs/_build/
90
+
91
+ # PyBuilder
92
+ .pybuilder/
93
+ target/
94
+
95
+ # Jupyter Notebook
96
+ .ipynb_checkpoints
97
+
98
+ # IPython
99
+ profile_default/
100
+ ipython_config.py
101
+
102
+ # pyenv
103
+ # For a library or package, you might want to ignore these files since the code is
104
+ # intended to run in multiple environments; otherwise, check them in:
105
+ # .python-version
106
+
107
+ # pipenv
108
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
109
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
110
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
111
+ # install all needed dependencies.
112
+ #Pipfile.lock
113
+
114
+ # poetry
115
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
116
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
117
+ # commonly ignored for libraries.
118
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
119
+ #poetry.lock
120
+
121
+ # pdm
122
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
123
+ #pdm.lock
124
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
125
+ # in version control.
126
+ # https://pdm.fming.dev/#use-with-ide
127
+ .pdm.toml
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule
134
+ celerybeat.pid
135
+
136
+ # SageMath parsed files
137
+ *.sage.py
138
+
139
+ # Environments
140
+ .env
141
+ .venv
142
+ env/
143
+ venv/
144
+ ENV/
145
+ env.bak/
146
+ venv.bak/
147
+
148
+ # Spyder project settings
149
+ .spyderproject
150
+ .spyproject
151
+
152
+ # Rope project settings
153
+ .ropeproject
154
+
155
+ # mkdocs documentation
156
+ /site
157
+
158
+ # mypy
159
+ .mypy_cache/
160
+ .dmypy.json
161
+ dmypy.json
162
+
163
+ # Pyre type checker
164
+ .pyre/
165
+
166
+ # pytype static type analyzer
167
+ .pytype/
168
+
169
+ # Cython debug symbols
170
+ cython_debug/
171
+
172
+ # PyCharm
173
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
174
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
175
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
176
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
177
+ .idea/
178
+
179
+ # Logs
180
+ logs
181
+ *.log
182
+ npm-debug.log*
183
+ yarn-debug.log*
184
+ yarn-error.log*
185
+ lerna-debug.log*
186
+ .pnpm-debug.log*
187
+
188
+ # Diagnostic reports (https://nodejs.org/api/report.html)
189
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
190
+
191
+ # Runtime data
192
+ pids
193
+ *.pid
194
+ *.seed
195
+ *.pid.lock
196
+
197
+ # Directory for instrumented libs generated by jscoverage/JSCover
198
+ lib-cov
199
+
200
+ # Coverage directory used by tools like istanbul
201
+ coverage
202
+ *.lcov
203
+
204
+ # nyc test coverage
205
+ .nyc_output
206
+
207
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
208
+ .grunt
209
+
210
+ # Bower dependency directory (https://bower.io/)
211
+ bower_components
212
+
213
+ # node-waf configuration
214
+ .lock-wscript
215
+
216
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
217
+ build/Release
218
+
219
+ # Dependency directories
220
+ node_modules/
221
+ jspm_packages/
222
+
223
+ # Snowpack dependency directory (https://snowpack.dev/)
224
+ web_modules/
225
+
226
+ # TypeScript cache
227
+ *.tsbuildinfo
228
+
229
+ # Optional npm cache directory
230
+ .npm
231
+
232
+ # Optional eslint cache
233
+ .eslintcache
234
+
235
+ # Optional stylelint cache
236
+ .stylelintcache
237
+
238
+ # Microbundle cache
239
+ .rpt2_cache/
240
+ .rts2_cache_cjs/
241
+ .rts2_cache_es/
242
+ .rts2_cache_umd/
243
+
244
+ # Optional REPL history
245
+ .node_repl_history
246
+
247
+ # Output of 'npm pack'
248
+ *.tgz
249
+
250
+ # Yarn Integrity file
251
+ .yarn-integrity
252
+
253
+ # dotenv environment variable files
254
+ .env
255
+ .env.development.local
256
+ .env.test.local
257
+ .env.production.local
258
+ .env.local
259
+
260
+ # parcel-bundler cache (https://parceljs.org/)
261
+ .cache
262
+ .parcel-cache
263
+
264
+ # Next.js build output
265
+ .next
266
+ out
267
+
268
+ # Nuxt.js build / generate output
269
+ .nuxt
270
+ dist
271
+
272
+ # Gatsby files
273
+ .cache/
274
+ # Comment in the public line in if your project uses Gatsby and not Next.js
275
+ # https://nextjs.org/blog/next-9-1#public-directory-support
276
+ # public
277
+
278
+ # vuepress build output
279
+ .vuepress/dist
280
+
281
+ # vuepress v2.x temp and cache directory
282
+ .temp
283
+ .cache
284
+
285
+ # Docusaurus cache and generated files
286
+ .docusaurus
287
+
288
+ # Serverless directories
289
+ .serverless/
290
+
291
+ # FuseBox cache
292
+ .fusebox/
293
+
294
+ # DynamoDB Local files
295
+ .dynamodb/
296
+
297
+ # TernJS port file
298
+ .tern-port
299
+
300
+ # Stores VSCode versions used for testing VSCode extensions
301
+ .vscode-test
302
+
303
+ # yarn v2
304
+ .yarn/cache
305
+ .yarn/unplugged
306
+ .yarn/build-state.yml
307
+ .yarn/install-state.gz
308
+ .pnp.*
309
+
310
+ # cypress artifacts
311
+ cypress/videos
312
+ cypress/screenshots
313
+
314
+
315
+
316
+ /static/*
.prettierrc ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "useTabs": true,
3
+ "singleQuote": true,
4
+ "trailingComma": "none",
5
+ "printWidth": 100,
6
+ "plugins": ["prettier-plugin-svelte"],
7
+ "pluginSearchDirs": ["."],
8
+ "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
9
+ }
CHANGELOG.md ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.125] - 2024-05-19
9
+
10
+ ### Added
11
+
12
+ - **🔄 Updated UI**: Chat interface revamped with chat bubbles. Easily switch back to the old style via settings > interface > chat bubble UI.
13
+ - **📂 Enhanced Sidebar UI**: Model files, documents, prompts, and playground merged into Workspace for streamlined access.
14
+ - **🚀 Improved Many Model Interaction**: All responses now displayed simultaneously for a smoother experience.
15
+ - **🐍 Python Code Execution**: Execute Python code locally in the browser with libraries like 'requests', 'beautifulsoup4', 'numpy', 'pandas', 'seaborn', 'matplotlib', 'scikit-learn', 'scipy', 'regex'.
16
+ - **🧠 Experimental Memory Feature**: Manually input personal information you want LLMs to remember via settings > personalization > memory.
17
+ - **💾 Persistent Settings**: Settings now saved as config.json for convenience.
18
+ - **🩺 Health Check Endpoint**: Added for Docker deployment.
19
+ - **↕️ RTL Support**: Toggle chat direction via settings > interface > chat direction.
20
+ - **🖥️ PowerPoint Support**: RAG pipeline now supports PowerPoint documents.
21
+ - **🌐 Language Updates**: Ukrainian, Turkish, Arabic, Chinese, Serbian, Vietnamese updated; Punjabi added.
22
+
23
+ ### Changed
24
+
25
+ - **👤 Shared Chat Update**: Shared chat now includes creator user information.
26
+
27
+ ## [0.1.124] - 2024-05-08
28
+
29
+ ### Added
30
+
31
+ - **🖼️ Improved Chat Sidebar**: Now conveniently displays time ranges and organizes chats by today, yesterday, and more.
32
+ - **📜 Citations in RAG Feature**: Easily track the context fed to the LLM with added citations in the RAG feature.
33
+ - **🔒 Auth Disable Option**: Introducing the ability to disable authentication. Set 'WEBUI_AUTH' to False to disable authentication. Note: Only applicable for fresh installations without existing users.
34
+ - **📹 Enhanced YouTube RAG Pipeline**: Now supports non-English videos for an enriched experience.
35
+ - **🔊 Specify OpenAI TTS Models**: Customize your TTS experience by specifying OpenAI TTS models.
36
+ - **🔧 Additional Environment Variables**: Discover more environment variables in our comprehensive documentation at Open WebUI Documentation (https://docs.openwebui.com).
37
+ - **🌐 Language Support**: Arabic, Finnish, and Hindi added; Improved support for German, Vietnamese, and Chinese.
38
+
39
+ ### Fixed
40
+
41
+ - **🛠️ Model Selector Styling**: Addressed styling issues for improved user experience.
42
+ - **⚠️ Warning Messages**: Resolved backend warning messages.
43
+
44
+ ### Changed
45
+
46
+ - **📝 Title Generation**: Limited output to 50 tokens.
47
+ - **📦 Helm Charts**: Removed Helm charts, now available in a separate repository (https://github.com/open-webui/helm-charts).
48
+
49
+ ## [0.1.123] - 2024-05-02
50
+
51
+ ### Added
52
+
53
+ - **🎨 New Landing Page Design**: Refreshed design for a more modern look and optimized use of screen space.
54
+ - **📹 Youtube RAG Pipeline**: Introduces dedicated RAG pipeline for Youtube videos, enabling interaction with video transcriptions directly.
55
+ - **🔧 Enhanced Admin Panel**: Streamlined user management with options to add users directly or in bulk via CSV import.
56
+ - **👥 '@' Model Integration**: Easily switch to specific models during conversations; old collaborative chat feature phased out.
57
+ - **🌐 Language Enhancements**: Swedish translation added, plus improvements to German, Spanish, and the addition of Doge translation.
58
+
59
+ ### Fixed
60
+
61
+ - **🗑️ Delete Chat Shortcut**: Addressed issue where shortcut wasn't functioning.
62
+ - **🖼️ Modal Closing Bug**: Resolved unexpected closure of modal when dragging from within.
63
+ - **✏️ Edit Button Styling**: Fixed styling inconsistency with edit buttons.
64
+ - **🌐 Image Generation Compatibility Issue**: Rectified image generation compatibility issue with third-party APIs.
65
+ - **📱 iOS PWA Icon Fix**: Corrected iOS PWA home screen icon shape.
66
+ - **🔍 Scroll Gesture Bug**: Adjusted gesture sensitivity to prevent accidental activation when scrolling through code on mobile; now requires scrolling from the leftmost side to open the sidebar.
67
+
68
+ ### Changed
69
+
70
+ - **🔄 Unlimited Context Length**: Advanced settings now allow unlimited max context length (previously limited to 16000).
71
+ - **👑 Super Admin Assignment**: The first signup is automatically assigned a super admin role, unchangeable by other admins.
72
+ - **🛡️ Admin User Restrictions**: User action buttons from the admin panel are now disabled for users with admin roles.
73
+ - **🔝 Default Model Selector**: Set as default model option now exclusively available on the landing page.
74
+
75
+ ## [0.1.122] - 2024-04-27
76
+
77
+ ### Added
78
+
79
+ - **🌟 Enhanced RAG Pipeline**: Now with hybrid searching via 'BM25', reranking powered by 'CrossEncoder', and configurable relevance score thresholds.
80
+ - **🛢️ External Database Support**: Seamlessly connect to custom SQLite or Postgres databases using the 'DATABASE_URL' environment variable.
81
+ - **🌐 Remote ChromaDB Support**: Introducing the capability to connect to remote ChromaDB servers.
82
+ - **👨‍💼 Improved Admin Panel**: Admins can now conveniently check users' chat lists and last active status directly from the admin panel.
83
+ - **🎨 Splash Screen**: Introducing a loading splash screen for a smoother user experience.
84
+ - **🌍 Language Support Expansion**: Added support for Bangla (bn-BD), along with enhancements to Chinese, Spanish, and Ukrainian translations.
85
+ - **💻 Improved LaTeX Rendering Performance**: Enjoy faster rendering times for LaTeX equations.
86
+ - **🔧 More Environment Variables**: Explore additional environment variables in our documentation (https://docs.openwebui.com), including the 'ENABLE_LITELLM' option to manage memory usage.
87
+
88
+ ### Fixed
89
+
90
+ - **🔧 Ollama Compatibility**: Resolved errors occurring when Ollama server version isn't an integer, such as SHA builds or RCs.
91
+ - **🐛 Various OpenAI API Issues**: Addressed several issues related to the OpenAI API.
92
+ - **🛑 Stop Sequence Issue**: Fixed the problem where the stop sequence with a backslash '\' was not functioning.
93
+ - **🔤 Font Fallback**: Corrected font fallback issue.
94
+
95
+ ### Changed
96
+
97
+ - **⌨️ Prompt Input Behavior on Mobile**: Enter key prompt submission disabled on mobile devices for improved user experience.
98
+
99
+ ## [0.1.121] - 2024-04-24
100
+
101
+ ### Fixed
102
+
103
+ - **🔧 Translation Issues**: Addressed various translation discrepancies.
104
+ - **🔒 LiteLLM Security Fix**: Updated LiteLLM version to resolve a security vulnerability.
105
+ - **🖥️ HTML Tag Display**: Rectified the issue where the '< br >' tag wasn't displaying correctly.
106
+ - **🔗 WebSocket Connection**: Resolved the failure of WebSocket connection under HTTPS security for ComfyUI server.
107
+ - **📜 FileReader Optimization**: Implemented FileReader initialization per image in multi-file drag & drop to ensure reusability.
108
+ - **🏷️ Tag Display**: Corrected tag display inconsistencies.
109
+ - **📦 Archived Chat Styling**: Fixed styling issues in archived chat.
110
+ - **🔖 Safari Copy Button Bug**: Addressed the bug where the copy button failed to copy links in Safari.
111
+
112
+ ## [0.1.120] - 2024-04-20
113
+
114
+ ### Added
115
+
116
+ - **📦 Archive Chat Feature**: Easily archive chats with a new sidebar button, and access archived chats via the profile button > archived chats.
117
+ - **🔊 Configurable Text-to-Speech Endpoint**: Customize your Text-to-Speech experience with configurable OpenAI endpoints.
118
+ - **🛠️ Improved Error Handling**: Enhanced error message handling for connection failures.
119
+ - **⌨️ Enhanced Shortcut**: When editing messages, use ctrl/cmd+enter to save and submit, and esc to close.
120
+ - **🌐 Language Support**: Added support for Georgian and enhanced translations for Portuguese and Vietnamese.
121
+
122
+ ### Fixed
123
+
124
+ - **🔧 Model Selector**: Resolved issue where default model selection was not saving.
125
+ - **🔗 Share Link Copy Button**: Fixed bug where the copy button wasn't copying links in Safari.
126
+ - **🎨 Light Theme Styling**: Addressed styling issue with the light theme.
127
+
128
+ ## [0.1.119] - 2024-04-16
129
+
130
+ ### Added
131
+
132
+ - **🌟 Enhanced RAG Embedding Support**: Ollama, and OpenAI models can now be used for RAG embedding model.
133
+ - **🔄 Seamless Integration**: Copy 'ollama run <model name>' directly from Ollama page to easily select and pull models.
134
+ - **🏷️ Tagging Feature**: Add tags to chats directly via the sidebar chat menu.
135
+ - **📱 Mobile Accessibility**: Swipe left and right on mobile to effortlessly open and close the sidebar.
136
+ - **🔍 Improved Navigation**: Admin panel now supports pagination for user list.
137
+ - **🌍 Additional Language Support**: Added Polish language support.
138
+
139
+ ### Fixed
140
+
141
+ - **🌍 Language Enhancements**: Vietnamese and Spanish translations have been improved.
142
+ - **🔧 Helm Fixes**: Resolved issues with Helm trailing slash and manifest.json.
143
+
144
+ ### Changed
145
+
146
+ - **🐳 Docker Optimization**: Updated docker image build process to utilize 'uv' for significantly faster builds compared to 'pip3'.
147
+
148
+ ## [0.1.118] - 2024-04-10
149
+
150
+ ### Added
151
+
152
+ - **🦙 Ollama and CUDA Images**: Added support for ':ollama' and ':cuda' tagged images.
153
+ - **👍 Enhanced Response Rating**: Now you can annotate your ratings for better feedback.
154
+ - **👤 User Initials Profile Photo**: User initials are now the default profile photo.
155
+ - **🔍 Update RAG Embedding Model**: Customize RAG embedding model directly in document settings.
156
+ - **🌍 Additional Language Support**: Added Turkish language support.
157
+
158
+ ### Fixed
159
+
160
+ - **🔒 Share Chat Permission**: Resolved issue with chat sharing permissions.
161
+ - **🛠 Modal Close**: Modals can now be closed using the Esc key.
162
+
163
+ ### Changed
164
+
165
+ - **🎨 Admin Panel Styling**: Refreshed styling for the admin panel.
166
+ - **🐳 Docker Image Build**: Updated docker image build process for improved efficiency.
167
+
168
+ ## [0.1.117] - 2024-04-03
169
+
170
+ ### Added
171
+
172
+ - 🗨️ **Local Chat Sharing**: Share chat links seamlessly between users.
173
+ - 🔑 **API Key Generation Support**: Generate secret keys to leverage Open WebUI with OpenAI libraries.
174
+ - 📄 **Chat Download as PDF**: Easily download chats in PDF format.
175
+ - 📝 **Improved Logging**: Enhancements to logging functionality.
176
+ - 📧 **Trusted Email Authentication**: Authenticate using a trusted email header.
177
+
178
+ ### Fixed
179
+
180
+ - 🌷 **Enhanced Dutch Translation**: Improved translation for Dutch users.
181
+ - ⚪ **White Theme Styling**: Resolved styling issue with the white theme.
182
+ - 📜 **LaTeX Chat Screen Overflow**: Fixed screen overflow issue with LaTeX rendering.
183
+ - 🔒 **Security Patches**: Applied necessary security patches.
184
+
185
+ ## [0.1.116] - 2024-03-31
186
+
187
+ ### Added
188
+
189
+ - **🔄 Enhanced UI**: Model selector now conveniently located in the navbar, enabling seamless switching between multiple models during conversations.
190
+ - **🔍 Improved Model Selector**: Directly pull a model from the selector/Models now display detailed information for better understanding.
191
+ - **💬 Webhook Support**: Now compatible with Google Chat and Microsoft Teams.
192
+ - **🌐 Localization**: Korean translation (I18n) now available.
193
+ - **🌑 Dark Theme**: OLED dark theme introduced for reduced strain during prolonged usage.
194
+ - **🏷️ Tag Autocomplete**: Dropdown feature added for effortless chat tagging.
195
+
196
+ ### Fixed
197
+
198
+ - **🔽 Auto-Scrolling**: Addressed OpenAI auto-scrolling issue.
199
+ - **🏷️ Tag Validation**: Implemented tag validation to prevent empty string tags.
200
+ - **🚫 Model Whitelisting**: Resolved LiteLLM model whitelisting issue.
201
+ - **✅ Spelling**: Corrected various spelling issues for improved readability.
202
+
203
+ ## [0.1.115] - 2024-03-24
204
+
205
+ ### Added
206
+
207
+ - **🔍 Custom Model Selector**: Easily find and select custom models with the new search filter feature.
208
+ - **🛑 Cancel Model Download**: Added the ability to cancel model downloads.
209
+ - **🎨 Image Generation ComfyUI**: Image generation now supports ComfyUI.
210
+ - **🌟 Updated Light Theme**: Updated the light theme for a fresh look.
211
+ - **🌍 Additional Language Support**: Now supporting Bulgarian, Italian, Portuguese, Japanese, and Dutch.
212
+
213
+ ### Fixed
214
+
215
+ - **🔧 Fixed Broken Experimental GGUF Upload**: Resolved issues with experimental GGUF upload functionality.
216
+
217
+ ### Changed
218
+
219
+ - **🔄 Vector Storage Reset Button**: Moved the reset vector storage button to document settings.
220
+
221
+ ## [0.1.114] - 2024-03-20
222
+
223
+ ### Added
224
+
225
+ - **🔗 Webhook Integration**: Now you can subscribe to new user sign-up events via webhook. Simply navigate to the admin panel > admin settings > webhook URL.
226
+ - **🛡️ Enhanced Model Filtering**: Alongside Ollama, OpenAI proxy model whitelisting, we've added model filtering functionality for LiteLLM proxy.
227
+ - **🌍 Expanded Language Support**: Spanish, Catalan, and Vietnamese languages are now available, with improvements made to others.
228
+
229
+ ### Fixed
230
+
231
+ - **🔧 Input Field Spelling**: Resolved issue with spelling mistakes in input fields.
232
+ - **🖊️ Light Mode Styling**: Fixed styling issue with light mode in document adding.
233
+
234
+ ### Changed
235
+
236
+ - **🔄 Language Sorting**: Languages are now sorted alphabetically by their code for improved organization.
237
+
238
+ ## [0.1.113] - 2024-03-18
239
+
240
+ ### Added
241
+
242
+ - 🌍 **Localization**: You can now change the UI language in Settings > General. We support Ukrainian, German, Farsi (Persian), Traditional and Simplified Chinese and French translations. You can help us to translate the UI into your language! More info in our [CONTRIBUTION.md](https://github.com/open-webui/open-webui/blob/main/docs/CONTRIBUTING.md#-translations-and-internationalization).
243
+ - 🎨 **System-wide Theme**: Introducing a new system-wide theme for enhanced visual experience.
244
+
245
+ ### Fixed
246
+
247
+ - 🌑 **Dark Background on Select Fields**: Improved readability by adding a dark background to select fields, addressing issues on certain browsers/devices.
248
+ - **Multiple OPENAI_API_BASE_URLS Issue**: Resolved issue where multiple base URLs caused conflicts when one wasn't functioning.
249
+ - **RAG Encoding Issue**: Fixed encoding problem in RAG.
250
+ - **npm Audit Fix**: Addressed npm audit findings.
251
+ - **Reduced Scroll Threshold**: Improved auto-scroll experience by reducing the scroll threshold from 50px to 5px.
252
+
253
+ ### Changed
254
+
255
+ - 🔄 **Sidebar UI Update**: Updated sidebar UI to feature a chat menu dropdown, replacing two icons for improved navigation.
256
+
257
+ ## [0.1.112] - 2024-03-15
258
+
259
+ ### Fixed
260
+
261
+ - 🗨️ Resolved chat malfunction after image generation.
262
+ - 🎨 Fixed various RAG issues.
263
+ - 🧪 Rectified experimental broken GGUF upload logic.
264
+
265
+ ## [0.1.111] - 2024-03-10
266
+
267
+ ### Added
268
+
269
+ - 🛡️ **Model Whitelisting**: Admins now have the ability to whitelist models for users with the 'user' role.
270
+ - 🔄 **Update All Models**: Added a convenient button to update all models at once.
271
+ - 📄 **Toggle PDF OCR**: Users can now toggle PDF OCR option for improved parsing performance.
272
+ - 🎨 **DALL-E Integration**: Introduced DALL-E integration for image generation alongside automatic1111.
273
+ - 🛠️ **RAG API Refactoring**: Refactored RAG logic and exposed its API, with additional documentation to follow.
274
+
275
+ ### Fixed
276
+
277
+ - 🔒 **Max Token Settings**: Added max token settings for anthropic/claude-3-sonnet-20240229 (Issue #1094).
278
+ - 🔧 **Misalignment Issue**: Corrected misalignment of Edit and Delete Icons when Chat Title is Empty (Issue #1104).
279
+ - 🔄 **Context Loss Fix**: Resolved RAG losing context on model response regeneration with Groq models via API key (Issue #1105).
280
+ - 📁 **File Handling Bug**: Addressed File Not Found Notification when Dropping a Conversation Element (Issue #1098).
281
+ - 🖱️ **Dragged File Styling**: Fixed dragged file layover styling issue.
282
+
283
+ ## [0.1.110] - 2024-03-06
284
+
285
+ ### Added
286
+
287
+ - **🌐 Multiple OpenAI Servers Support**: Enjoy seamless integration with multiple OpenAI-compatible APIs, now supported natively.
288
+
289
+ ### Fixed
290
+
291
+ - **🔍 OCR Issue**: Resolved PDF parsing issue caused by OCR malfunction.
292
+ - **🚫 RAG Issue**: Fixed the RAG functionality, ensuring it operates smoothly.
293
+ - **📄 "Add Docs" Model Button**: Addressed the non-functional behavior of the "Add Docs" model button.
294
+
295
+ ## [0.1.109] - 2024-03-06
296
+
297
+ ### Added
298
+
299
+ - **🔄 Multiple Ollama Servers Support**: Enjoy enhanced scalability and performance with support for multiple Ollama servers in a single WebUI. Load balancing features are now available, providing improved efficiency (#788, #278).
300
+ - **🔧 Support for Claude 3 and Gemini**: Responding to user requests, we've expanded our toolset to include Claude 3 and Gemini, offering a wider range of functionalities within our platform (#1064).
301
+ - **🔍 OCR Functionality for PDF Loader**: We've augmented our PDF loader with Optical Character Recognition (OCR) capabilities. Now, extract text from scanned documents and images within PDFs, broadening the scope of content processing (#1050).
302
+
303
+ ### Fixed
304
+
305
+ - **🛠️ RAG Collection**: Implemented a dynamic mechanism to recreate RAG collections, ensuring users have up-to-date and accurate data (#1031).
306
+ - **📝 User Agent Headers**: Fixed issue of RAG web requests being sent with empty user_agent headers, reducing rejections from certain websites. Realistic headers are now utilized for these requests (#1024).
307
+ - **⏹️ Playground Cancel Functionality**: Introducing a new "Cancel" option for stopping Ollama generation in the Playground, enhancing user control and usability (#1006).
308
+ - **🔤 Typographical Error in 'ASSISTANT' Field**: Corrected a typographical error in the 'ASSISTANT' field within the GGUF model upload template for accuracy and consistency (#1061).
309
+
310
+ ### Changed
311
+
312
+ - **🔄 Refactored Message Deletion Logic**: Streamlined message deletion process for improved efficiency and user experience, simplifying interactions within the platform (#1004).
313
+ - **⚠️ Deprecation of `OLLAMA_API_BASE_URL`**: Deprecated `OLLAMA_API_BASE_URL` environment variable; recommend using `OLLAMA_BASE_URL` instead. Refer to our documentation for further details.
314
+
315
+ ## [0.1.108] - 2024-03-02
316
+
317
+ ### Added
318
+
319
+ - **🎮 Playground Feature (Beta)**: Explore the full potential of the raw API through an intuitive UI with our new playground feature, accessible to admins. Simply click on the bottom name area of the sidebar to access it. The playground feature offers two modes text completion (notebook) and chat completion. As it's in beta, please report any issues you encounter.
320
+ - **🛠️ Direct Database Download for Admins**: Admins can now download the database directly from the WebUI via the admin settings.
321
+ - **🎨 Additional RAG Settings**: Customize your RAG process with the ability to edit the TOP K value. Navigate to Documents > Settings > General to make changes.
322
+ - **🖥️ UI Improvements**: Tooltips now available in the input area and sidebar handle. More tooltips will be added across other parts of the UI.
323
+
324
+ ### Fixed
325
+
326
+ - Resolved input autofocus issue on mobile when the sidebar is open, making it easier to use.
327
+ - Corrected numbered list display issue in Safari (#963).
328
+ - Restricted user ability to delete chats without proper permissions (#993).
329
+
330
+ ### Changed
331
+
332
+ - **Simplified Ollama Settings**: Ollama settings now don't require the `/api` suffix. You can now utilize the Ollama base URL directly, e.g., `http://localhost:11434`. Also, an `OLLAMA_BASE_URL` environment variable has been added.
333
+ - **Database Renaming**: Starting from this release, `ollama.db` will be automatically renamed to `webui.db`.
334
+
335
+ ## [0.1.107] - 2024-03-01
336
+
337
+ ### Added
338
+
339
+ - **🚀 Makefile and LLM Update Script**: Included Makefile and a script for LLM updates in the repository.
340
+
341
+ ### Fixed
342
+
343
+ - Corrected issue where links in the settings modal didn't appear clickable (#960).
344
+ - Fixed problem with web UI port not taking effect due to incorrect environment variable name in run-compose.sh (#996).
345
+ - Enhanced user experience by displaying chat in browser title and enabling automatic scrolling to the bottom (#992).
346
+
347
+ ### Changed
348
+
349
+ - Upgraded toast library from `svelte-french-toast` to `svelte-sonner` for a more polished UI.
350
+ - Enhanced accessibility with the addition of dark mode on the authentication page.
351
+
352
+ ## [0.1.106] - 2024-02-27
353
+
354
+ ### Added
355
+
356
+ - **🎯 Auto-focus Feature**: The input area now automatically focuses when initiating or opening a chat conversation.
357
+
358
+ ### Fixed
359
+
360
+ - Corrected typo from "HuggingFace" to "Hugging Face" (Issue #924).
361
+ - Resolved bug causing errors in chat completion API calls to OpenAI due to missing "num_ctx" parameter (Issue #927).
362
+ - Fixed issues preventing text editing, selection, and cursor retention in the input field (Issue #940).
363
+ - Fixed a bug where defining an OpenAI-compatible API server using 'OPENAI_API_BASE_URL' containing 'openai' string resulted in hiding models not containing 'gpt' string from the model menu. (Issue #930)
364
+
365
+ ## [0.1.105] - 2024-02-25
366
+
367
+ ### Added
368
+
369
+ - **📄 Document Selection**: Now you can select and delete multiple documents at once for easier management.
370
+
371
+ ### Changed
372
+
373
+ - **🏷️ Document Pre-tagging**: Simply click the "+" button at the top, enter tag names in the popup window, or select from a list of existing tags. Then, upload files with the added tags for streamlined organization.
374
+
375
+ ## [0.1.104] - 2024-02-25
376
+
377
+ ### Added
378
+
379
+ - **🔄 Check for Updates**: Keep your system current by checking for updates conveniently located in Settings > About.
380
+ - **🗑️ Automatic Tag Deletion**: Unused tags on the sidebar will now be deleted automatically with just a click.
381
+
382
+ ### Changed
383
+
384
+ - **🎨 Modernized Styling**: Enjoy a refreshed look with updated styling for a more contemporary experience.
385
+
386
+ ## [0.1.103] - 2024-02-25
387
+
388
+ ### Added
389
+
390
+ - **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality.
391
+
392
+ - Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag.
393
+ - When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.
394
+
395
+ - **🖼️ Image Generation Enhancements**: Introducing Advanced Settings with Image Preview Feature.
396
+ - Customize image generation by setting the number of steps; defaults to A1111 value.
397
+
398
+ ### Fixed
399
+
400
+ - Resolved issue with RAG scan halting document loading upon encountering unsupported MIME types or exceptions (Issue #866).
401
+
402
+ ### Changed
403
+
404
+ - Ollama is no longer required to run Open WebUI.
405
+ - Access our comprehensive documentation at [Open WebUI Documentation](https://docs.openwebui.com/).
406
+
407
+ ## [0.1.102] - 2024-02-22
408
+
409
+ ### Added
410
+
411
+ - **🖼️ Image Generation**: Generate Images using the AUTOMATIC1111/stable-diffusion-webui API. You can set this up in Settings > Images.
412
+ - **📝 Change title generation prompt**: Change the prompt used to generate titles for your chats. You can set this up in the Settings > Interface.
413
+ - **🤖 Change embedding model**: Change the embedding model used to generate embeddings for your chats in the Dockerfile. Use any sentence transformer model from huggingface.co.
414
+ - **📢 CHANGELOG.md/Popup**: This popup will show you the latest changes.
415
+
416
+ ## [0.1.101] - 2024-02-22
417
+
418
+ ### Fixed
419
+
420
+ - LaTex output formatting issue (#828)
421
+
422
+ ### Changed
423
+
424
+ - Instead of having the previous 1.0.0-alpha.101, we switched to semantic versioning as a way to respect global conventions.
Caddyfile.localhost ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Run with
2
+ # caddy run --envfile ./example.env --config ./Caddyfile.localhost
3
+ #
4
+ # This is configured for
5
+ # - Automatic HTTPS (even for localhost)
6
+ # - Reverse Proxying to Ollama API Base URL (http://localhost:11434/api)
7
+ # - CORS
8
+ # - HTTP Basic Auth API Tokens (uncomment basicauth section)
9
+
10
+
11
+ # CORS Preflight (OPTIONS) + Request (GET, POST, PATCH, PUT, DELETE)
12
+ (cors-api) {
13
+ @match-cors-api-preflight method OPTIONS
14
+ handle @match-cors-api-preflight {
15
+ header {
16
+ Access-Control-Allow-Origin "{http.request.header.origin}"
17
+ Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
18
+ Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With"
19
+ Access-Control-Allow-Credentials "true"
20
+ Access-Control-Max-Age "3600"
21
+ defer
22
+ }
23
+ respond "" 204
24
+ }
25
+
26
+ @match-cors-api-request {
27
+ not {
28
+ header Origin "{http.request.scheme}://{http.request.host}"
29
+ }
30
+ header Origin "{http.request.header.origin}"
31
+ }
32
+ handle @match-cors-api-request {
33
+ header {
34
+ Access-Control-Allow-Origin "{http.request.header.origin}"
35
+ Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
36
+ Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With"
37
+ Access-Control-Allow-Credentials "true"
38
+ Access-Control-Max-Age "3600"
39
+ defer
40
+ }
41
+ }
42
+ }
43
+
44
+ # replace localhost with example.com or whatever
45
+ localhost {
46
+ ## HTTP Basic Auth
47
+ ## (uncomment to enable)
48
+ # basicauth {
49
+ # # see .example.env for how to generate tokens
50
+ # {env.OLLAMA_API_ID} {env.OLLAMA_API_TOKEN_DIGEST}
51
+ # }
52
+
53
+ handle /api/* {
54
+ # Comment to disable CORS
55
+ import cors-api
56
+
57
+ reverse_proxy localhost:11434
58
+ }
59
+
60
+ # Same-Origin Static Web Server
61
+ file_server {
62
+ root ./build/
63
+ }
64
+ }
Dockerfile ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ # Initialize device type args
3
+ # use build args in the docker build commmand with --build-arg="BUILDARG=true"
4
+ ARG USE_CUDA=false
5
+ ARG USE_OLLAMA=false
6
+ # Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
7
+ ARG USE_CUDA_VER=cu121
8
+ # any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
9
+ # Leaderboard: https://huggingface.co/spaces/mteb/leaderboard
10
+ # for better performance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB)
11
+ # IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them.
12
+ ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
13
+ ARG USE_RERANKING_MODEL=""
14
+ # Override at your own risk - non-root configurations are untested
15
+ ARG UID=0
16
+ ARG GID=0
17
+
18
+ ######## WebUI frontend ########
19
+ FROM --platform=$BUILDPLATFORM node:21-alpine3.19 as build
20
+
21
+ WORKDIR /app
22
+
23
+ COPY package.json package-lock.json ./
24
+ RUN npm ci
25
+
26
+ COPY . .
27
+ RUN npm run build
28
+
29
+ ######## WebUI backend ########
30
+ FROM python:3.11-slim-bookworm as base
31
+
32
+ # Use args
33
+ ARG USE_CUDA
34
+ ARG USE_OLLAMA
35
+ ARG USE_CUDA_VER
36
+ ARG USE_EMBEDDING_MODEL
37
+ ARG USE_RERANKING_MODEL
38
+ ARG UID
39
+ ARG GID
40
+
41
+ ## Basis ##
42
+ ENV ENV=prod \
43
+ PORT=8080 \
44
+ # pass build args to the build
45
+ USE_OLLAMA_DOCKER=${USE_OLLAMA} \
46
+ USE_CUDA_DOCKER=${USE_CUDA} \
47
+ USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
48
+ USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
49
+ USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
50
+
51
+ ## Basis URL Config ##
52
+ ENV OLLAMA_BASE_URL="/ollama" \
53
+ OPENAI_API_BASE_URL=""
54
+
55
+ ## API Key and Security Config ##
56
+ ENV OPENAI_API_KEY="" \
57
+ WEBUI_SECRET_KEY="" \
58
+ SCARF_NO_ANALYTICS=true \
59
+ DO_NOT_TRACK=true \
60
+ ANONYMIZED_TELEMETRY=false
61
+
62
+ # Use locally bundled version of the LiteLLM cost map json
63
+ # to avoid repetitive startup connections
64
+ ENV LITELLM_LOCAL_MODEL_COST_MAP="True"
65
+
66
+
67
+ #### Other models #########################################################
68
+ ## whisper TTS model settings ##
69
+ ENV WHISPER_MODEL="base" \
70
+ WHISPER_MODEL_DIR="/app/backend/data/cache/whisper/models"
71
+
72
+ ## RAG Embedding model settings ##
73
+ ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \
74
+ RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \
75
+ SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models"
76
+
77
+ ## Hugging Face download cache ##
78
+ ENV HF_HOME="/app/backend/data/cache/embedding/models"
79
+ #### Other models ##########################################################
80
+
81
+ WORKDIR /app/backend
82
+
83
+ ENV HOME /root
84
+ # Create user and group if not root
85
+ RUN if [ $UID -ne 0 ]; then \
86
+ if [ $GID -ne 0 ]; then \
87
+ addgroup --gid $GID app; \
88
+ fi; \
89
+ adduser --uid $UID --gid $GID --home $HOME --disabled-password --no-create-home app; \
90
+ fi
91
+
92
+ RUN mkdir -p $HOME/.cache/chroma
93
+ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry_user_id
94
+
95
+ # Make sure the user has access to the app and root directory
96
+ RUN chown -R $UID:$GID /app $HOME
97
+
98
+ RUN if [ "$USE_OLLAMA" = "true" ]; then \
99
+ apt-get update && \
100
+ # Install pandoc and netcat
101
+ apt-get install -y --no-install-recommends pandoc netcat-openbsd curl && \
102
+ # for RAG OCR
103
+ apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
104
+ # install helper tools
105
+ apt-get install -y --no-install-recommends curl jq && \
106
+ # install ollama
107
+ curl -fsSL https://ollama.com/install.sh | sh && \
108
+ # cleanup
109
+ rm -rf /var/lib/apt/lists/*; \
110
+ else \
111
+ apt-get update && \
112
+ # Install pandoc and netcat
113
+ apt-get install -y --no-install-recommends pandoc netcat-openbsd curl jq && \
114
+ # for RAG OCR
115
+ apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
116
+ # cleanup
117
+ rm -rf /var/lib/apt/lists/*; \
118
+ fi
119
+
120
+ # install python dependencies
121
+ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
122
+
123
+ RUN pip3 install uv && \
124
+ if [ "$USE_CUDA" = "true" ]; then \
125
+ # If you use CUDA the whisper and embedding model will be downloaded on first use
126
+ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
127
+ uv pip install --system -r requirements.txt --no-cache-dir && \
128
+ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
129
+ 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'])"; \
130
+ chown -R $UID:$GID /app/backend/data/; \
131
+ else \
132
+ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
133
+ uv pip install --system -r requirements.txt --no-cache-dir && \
134
+ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
135
+ 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'])"; \
136
+ chown -R $UID:$GID /app/backend/data/; \
137
+ fi
138
+
139
+
140
+
141
+ # copy embedding weight from build
142
+ # RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
143
+ # COPY --from=build /app/onnx /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2/onnx
144
+
145
+ # copy built frontend files
146
+ COPY --chown=$UID:$GID --from=build /app/build /app/build
147
+ COPY --chown=$UID:$GID --from=build /app/CHANGELOG.md /app/CHANGELOG.md
148
+ COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
149
+
150
+ # copy backend files
151
+ COPY --chown=$UID:$GID ./backend .
152
+
153
+ # HFSPACE:START
154
+ COPY --chown=$UID:$GID ./space/litellm_config.yaml ./data/litellm/config.yaml
155
+ # HFSPACE:END
156
+
157
+ EXPOSE 8080
158
+
159
+ HEALTHCHECK CMD curl --silent --fail http://localhost:8080/health | jq -e '.status == true' || exit 1
160
+
161
+ USER $UID:$GID
162
+
163
+ CMD [ "bash", "start.sh"]
INSTALLATION.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ### Installing Both Ollama and Open WebUI Using Kustomize
2
+
3
+ For cpu-only pod
4
+
5
+ ```bash
6
+ kubectl apply -f ./kubernetes/manifest/base
7
+ ```
8
+
9
+ For gpu-enabled pod
10
+
11
+ ```bash
12
+ kubectl apply -k ./kubernetes/manifest
13
+ ```
14
+
15
+ ### Installing Both Ollama and Open WebUI Using Helm
16
+
17
+ Package Helm file first
18
+
19
+ ```bash
20
+ helm package ./kubernetes/helm/
21
+ ```
22
+
23
+ For cpu-only pod
24
+
25
+ ```bash
26
+ helm install ollama-webui ./ollama-webui-*.tgz
27
+ ```
28
+
29
+ For gpu-enabled pod
30
+
31
+ ```bash
32
+ helm install ollama-webui ./ollama-webui-*.tgz --set ollama.resources.limits.nvidia.com/gpu="1"
33
+ ```
34
+
35
+ Check the `kubernetes/helm/values.yaml` file to know which parameters are available for customization
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Timothy Jaeryang Baek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ifneq ($(shell which docker-compose 2>/dev/null),)
3
+ DOCKER_COMPOSE := docker-compose
4
+ else
5
+ DOCKER_COMPOSE := docker compose
6
+ endif
7
+
8
+ install:
9
+ $(DOCKER_COMPOSE) up -d
10
+
11
+ remove:
12
+ @chmod +x confirm_remove.sh
13
+ @./confirm_remove.sh
14
+
15
+ start:
16
+ $(DOCKER_COMPOSE) start
17
+ startAndBuild:
18
+ $(DOCKER_COMPOSE) up -d --build
19
+
20
+ stop:
21
+ $(DOCKER_COMPOSE) stop
22
+
23
+ update:
24
+ # Calls the LLM update script
25
+ chmod +x update_ollama_models.sh
26
+ @./update_ollama_models.sh
27
+ @git pull
28
+ $(DOCKER_COMPOSE) down
29
+ # Make sure the ollama-webui container is stopped before rebuilding
30
+ @docker stop open-webui || true
31
+ $(DOCKER_COMPOSE) up --build -d
32
+ $(DOCKER_COMPOSE) start
33
+
README.md ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Open WebUI
3
+ emoji: 🐳
4
+ colorFrom: purple
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 8080
8
+ ---
9
+
10
+ # Open WebUI (Formerly Ollama WebUI) 👋
11
+
12
+ ![GitHub stars](https://img.shields.io/github/stars/open-webui/open-webui?style=social)
13
+ ![GitHub forks](https://img.shields.io/github/forks/open-webui/open-webui?style=social)
14
+ ![GitHub watchers](https://img.shields.io/github/watchers/open-webui/open-webui?style=social)
15
+ ![GitHub repo size](https://img.shields.io/github/repo-size/open-webui/open-webui)
16
+ ![GitHub language count](https://img.shields.io/github/languages/count/open-webui/open-webui)
17
+ ![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
18
+ ![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
19
+ ![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Follama-webui%2Follama-wbui&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)
20
+ [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
21
+ [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
22
+
23
+ Open WebUI is an extensible, feature-rich, and user-friendly self-hosted WebUI designed to operate entirely offline. It supports various LLM runners, including Ollama and OpenAI-compatible APIs. For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
24
+
25
+ ![Open WebUI Demo](./demo.gif)
26
+
27
+ ## Features ⭐
28
+
29
+ - 🖥️ **Intuitive Interface**: Our chat interface takes inspiration from ChatGPT, ensuring a user-friendly experience.
30
+
31
+ - 📱 **Responsive Design**: Enjoy a seamless experience on both desktop and mobile devices.
32
+
33
+ - ⚡ **Swift Responsiveness**: Enjoy fast and responsive performance.
34
+
35
+ - 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience.
36
+
37
+ - 🌈 **Theme Customization**: Choose from a variety of themes to personalize your Open WebUI experience.
38
+
39
+ - 💻 **Code Syntax Highlighting**: Enjoy enhanced code readability with our syntax highlighting feature.
40
+
41
+ - ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
42
+
43
+ - 📚 **Local RAG Integration**: Dive into the future of chat interactions with the groundbreaking Retrieval Augmented Generation (RAG) support. This feature seamlessly integrates document interactions into your chat experience. You can load documents directly into the chat or add files to your document library, effortlessly accessing them using `#` command in the prompt. In its alpha phase, occasional issues may arise as we actively refine and enhance this feature to ensure optimal performance and reliability.
44
+
45
+ - 🔍 **RAG Embedding Support**: Change the RAG embedding model directly in document settings, enhancing document processing. This feature supports Ollama and OpenAI models.
46
+
47
+ - 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by the URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
48
+
49
+ - 📜 **Prompt Preset Support**: Instantly access preset prompts using the `/` command in the chat input. Load predefined conversation starters effortlessly and expedite your interactions. Effortlessly import prompts through [Open WebUI Community](https://openwebui.com/) integration.
50
+
51
+ - 👍👎 **RLHF Annotation**: Empower your messages by rating them with thumbs up and thumbs down, followed by the option to provide textual feedback, facilitating the creation of datasets for Reinforcement Learning from Human Feedback (RLHF). Utilize your messages to train or fine-tune models, all while ensuring the confidentiality of locally saved data.
52
+
53
+ - 🏷️ **Conversation Tagging**: Effortlessly categorize and locate specific chats for quick reference and streamlined data collection.
54
+
55
+ - 📥🗑️ **Download/Delete Models**: Easily download or remove models directly from the web UI.
56
+
57
+ - 🔄 **Update All Ollama Models**: Easily update locally installed models all at once with a convenient button, streamlining model management.
58
+
59
+ - ⬆️ **GGUF File Model Creation**: Effortlessly create Ollama models by uploading GGUF files directly from the web UI. Streamlined process with options to upload from your machine or download GGUF files from Hugging Face.
60
+
61
+ - 🤖 **Multiple Model Support**: Seamlessly switch between different chat models for diverse interactions.
62
+
63
+ - 🔄 **Multi-Modal Support**: Seamlessly engage with models that support multimodal interactions, including images (e.g., LLava).
64
+
65
+ - 🧩 **Modelfile Builder**: Easily create Ollama modelfiles via the web UI. Create and add characters/agents, customize chat elements, and import modelfiles effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
66
+
67
+ - ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
68
+
69
+ - 💬 **Collaborative Chat**: Harness the collective intelligence of multiple models by seamlessly orchestrating group conversations. Use the `@` command to specify the model, enabling dynamic and diverse dialogues within your chat interface. Immerse yourself in the collective intelligence woven into your chat environment.
70
+
71
+ - 🗨️ **Local Chat Sharing**: Generate and share chat links seamlessly between users, enhancing collaboration and communication.
72
+
73
+ - 🔄 **Regeneration History Access**: Easily revisit and explore your entire regeneration history.
74
+
75
+ - 📜 **Chat History**: Effortlessly access and manage your conversation history.
76
+
77
+ - 📬 **Archive Chats**: Effortlessly store away completed conversations with LLMs for future reference, maintaining a tidy and clutter-free chat interface while allowing for easy retrieval and reference.
78
+
79
+ - 📤📥 **Import/Export Chat History**: Seamlessly move your chat data in and out of the platform.
80
+
81
+ - 🗣️ **Voice Input Support**: Engage with your model through voice interactions; enjoy the convenience of talking to your model directly. Additionally, explore the option for sending voice input automatically after 3 seconds of silence for a streamlined experience.
82
+
83
+ - 🔊 **Configurable Text-to-Speech Endpoint**: Customize your Text-to-Speech experience with configurable OpenAI endpoints.
84
+
85
+ - ⚙️ **Fine-Tuned Control with Advanced Parameters**: Gain a deeper level of control by adjusting parameters such as temperature and defining your system prompts to tailor the conversation to your specific preferences and needs.
86
+
87
+ - 🎨🤖 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using options such as AUTOMATIC1111 API (local), ComfyUI (local), and DALL-E, enriching your chat experience with dynamic visual content.
88
+
89
+ - 🤝 **OpenAI API Integration**: Effortlessly integrate OpenAI-compatible API for versatile conversations alongside Ollama models. Customize the API Base URL to link with **LMStudio, Mistral, OpenRouter, and more**.
90
+
91
+ - ✨ **Multiple OpenAI-Compatible API Support**: Seamlessly integrate and customize various OpenAI-compatible APIs, enhancing the versatility of your chat interactions.
92
+
93
+ - 🔑 **API Key Generation Support**: Generate secret keys to leverage Open WebUI with OpenAI libraries, simplifying integration and development.
94
+
95
+ - 🔗 **External Ollama Server Connection**: Seamlessly link to an external Ollama server hosted on a different address by configuring the environment variable.
96
+
97
+ - 🔀 **Multiple Ollama Instance Load Balancing**: Effortlessly distribute chat requests across multiple Ollama instances for enhanced performance and reliability.
98
+
99
+ - 👥 **Multi-User Management**: Easily oversee and administer users via our intuitive admin panel, streamlining user management processes.
100
+
101
+ - 🔗 **Webhook Integration**: Subscribe to new user sign-up events via webhook (compatible with Google Chat and Microsoft Teams), providing real-time notifications and automation capabilities.
102
+
103
+ - 🛡️ **Model Whitelisting**: Admins can whitelist models for users with the 'user' role, enhancing security and access control.
104
+
105
+ - 📧 **Trusted Email Authentication**: Authenticate using a trusted email header, adding an additional layer of security and authentication.
106
+
107
+ - 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
108
+
109
+ - 🔒 **Backend Reverse Proxy Support**: Bolster security through direct communication between Open WebUI backend and Ollama. This key feature eliminates the need to expose Ollama over LAN. Requests made to the '/ollama/api' route from the web UI are seamlessly redirected to Ollama from the backend, enhancing overall system security.
110
+
111
+ - 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
112
+
113
+ - 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates and new features.
114
+
115
+ ## 🔗 Also Check Out Open WebUI Community!
116
+
117
+ Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Open WebUI! 🚀
118
+
119
+ ## How to Install 🚀
120
+
121
+ > [!NOTE]
122
+ > Please note that for certain Docker environments, additional configurations might be needed. If you encounter any connection issues, our detailed guide on [Open WebUI Documentation](https://docs.openwebui.com/) is ready to assist you.
123
+
124
+ ### Quick Start with Docker 🐳
125
+
126
+ > [!WARNING]
127
+ > 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.
128
+
129
+ > [!TIP]
130
+ > 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.
131
+
132
+ ### Installation with Default Configuration
133
+
134
+ - **If Ollama is on your computer**, use this command:
135
+
136
+ ```bash
137
+ 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
138
+ ```
139
+
140
+ - **If Ollama is on a Different Server**, use this command:
141
+
142
+ To connect to Ollama on another server, change the `OLLAMA_BASE_URL` to the server's URL:
143
+
144
+ ```bash
145
+ 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
146
+ ```
147
+
148
+ - **To run Open WebUI with Nvidia GPU support**, use this command:
149
+
150
+ ```bash
151
+ docker run -d -p 3000:8080 --gpus all --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:cuda
152
+ ```
153
+
154
+ ### Installation for OpenAI API Usage Only
155
+
156
+ - **If you're only using OpenAI API**, use this command:
157
+
158
+ ```bash
159
+ docker run -d -p 3000:8080 -e OPENAI_API_KEY=your_secret_key -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
160
+ ```
161
+
162
+ ### Installing Open WebUI with Bundled Ollama Support
163
+
164
+ This installation method uses a single container image that bundles Open WebUI with Ollama, allowing for a streamlined setup via a single command. Choose the appropriate command based on your hardware setup:
165
+
166
+ - **With GPU Support**:
167
+ Utilize GPU resources by running the following command:
168
+
169
+ ```bash
170
+ docker run -d -p 3000:8080 --gpus=all -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:ollama
171
+ ```
172
+
173
+ - **For CPU Only**:
174
+ If you're not using a GPU, use this command instead:
175
+
176
+ ```bash
177
+ docker run -d -p 3000:8080 -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:ollama
178
+ ```
179
+
180
+ Both commands facilitate a built-in, hassle-free installation of both Open WebUI and Ollama, ensuring that you can get everything up and running swiftly.
181
+
182
+ After installation, you can access Open WebUI at [http://localhost:3000](http://localhost:3000). Enjoy! 😄
183
+
184
+ ### Other Installation Methods
185
+
186
+ We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
187
+
188
+ ### Troubleshooting
189
+
190
+ Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s).
191
+
192
+ #### Open WebUI: Server Connection Error
193
+
194
+ If you're experiencing connection issues, it’s often due to the WebUI docker container not being able to reach the Ollama server at 127.0.0.1:11434 (host.docker.internal:11434) inside the container . Use the `--network=host` flag in your docker command to resolve this. Note that the port changes from 3000 to 8080, resulting in the link: `http://localhost:8080`.
195
+
196
+ **Example Docker Command**:
197
+
198
+ ```bash
199
+ docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main
200
+ ```
201
+
202
+ ### Keeping Your Docker Installation Up-to-Date
203
+
204
+ In case you want to update your local Docker installation to the latest version, you can do it with [Watchtower](https://containrrr.dev/watchtower/):
205
+
206
+ ```bash
207
+ docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once open-webui
208
+ ```
209
+
210
+ In the last part of the command, replace `open-webui` with your container name if it is different.
211
+
212
+ ### Moving from Ollama WebUI to Open WebUI
213
+
214
+ Check our Migration Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/migration/).
215
+
216
+ ## What's Next? 🌟
217
+
218
+ Discover upcoming features on our roadmap in the [Open WebUI Documentation](https://docs.openwebui.com/roadmap/).
219
+
220
+ ## Supporters ✨
221
+
222
+ A big shoutout to our amazing supporters who's helping to make this project possible! 🙏
223
+
224
+ ### Platinum Sponsors 🤍
225
+
226
+ - We're looking for Sponsors!
227
+
228
+ ### Acknowledgments
229
+
230
+ Special thanks to [Prof. Lawrence Kim](https://www.lhkim.com/) and [Prof. Nick Vincent](https://www.nickmvincent.com/) for their invaluable support and guidance in shaping this project into a research endeavor. Grateful for your mentorship throughout the journey! 🙌
231
+
232
+ ## License 📜
233
+
234
+ This project is licensed under the [MIT License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
235
+
236
+ ## Support 💬
237
+
238
+ If you have any questions, suggestions, or need assistance, please open an issue or join our
239
+ [Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
240
+
241
+ ## Star History
242
+
243
+ <a href="https://star-history.com/#open-webui/open-webui&Date">
244
+ <picture>
245
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=open-webui/open-webui&type=Date&theme=dark" />
246
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=open-webui/open-webui&type=Date" />
247
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=open-webui/open-webui&type=Date" />
248
+ </picture>
249
+ </a>
250
+
251
+ ---
252
+
253
+ Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open WebUI even more amazing together! 💪
TROUBLESHOOTING.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Open WebUI Troubleshooting Guide
2
+
3
+ ## Understanding the Open WebUI Architecture
4
+
5
+ The Open WebUI system is designed to streamline interactions between the client (your browser) and the Ollama API. At the heart of this design is a backend reverse proxy, enhancing security and resolving CORS issues.
6
+
7
+ - **How it Works**: The Open WebUI is designed to interact with the Ollama API through a specific route. When a request is made from the WebUI to Ollama, it is not directly sent to the Ollama API. Initially, the request is sent to the Open WebUI backend via `/ollama` route. From there, the backend is responsible for forwarding the request to the Ollama API. This forwarding is accomplished by using the route specified in the `OLLAMA_BASE_URL` environment variable. Therefore, a request made to `/ollama` in the WebUI is effectively the same as making a request to `OLLAMA_BASE_URL` in the backend. For instance, a request to `/ollama/api/tags` in the WebUI is equivalent to `OLLAMA_BASE_URL/api/tags` in the backend.
8
+
9
+ - **Security Benefits**: This design prevents direct exposure of the Ollama API to the frontend, safeguarding against potential CORS (Cross-Origin Resource Sharing) issues and unauthorized access. Requiring authentication to access the Ollama API further enhances this security layer.
10
+
11
+ ## Open WebUI: Server Connection Error
12
+
13
+ If you're experiencing connection issues, it’s often due to the WebUI docker container not being able to reach the Ollama server at 127.0.0.1:11434 (host.docker.internal:11434) inside the container . Use the `--network=host` flag in your docker command to resolve this. Note that the port changes from 3000 to 8080, resulting in the link: `http://localhost:8080`.
14
+
15
+ **Example Docker Command**:
16
+
17
+ ```bash
18
+ docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main
19
+ ```
20
+
21
+ ### General Connection Errors
22
+
23
+ **Ensure Ollama Version is Up-to-Date**: Always start by checking that you have the latest version of Ollama. Visit [Ollama's official site](https://ollama.com/) for the latest updates.
24
+
25
+ **Troubleshooting Steps**:
26
+
27
+ 1. **Verify Ollama URL Format**:
28
+ - When running the Web UI container, ensure the `OLLAMA_BASE_URL` is correctly set. (e.g., `http://192.168.1.1:11434` for different host setups).
29
+ - In the Open WebUI, navigate to "Settings" > "General".
30
+ - Confirm that the Ollama Server URL is correctly set to `[OLLAMA URL]` (e.g., `http://localhost:11434`).
31
+
32
+ By following these enhanced troubleshooting steps, connection issues should be effectively resolved. For further assistance or queries, feel free to reach out to us on our community Discord.
backend/.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ .env
3
+ _old
4
+ uploads
5
+ .ipynb_checkpoints
6
+ *.db
7
+ _test
8
+ !/data
9
+ /data/*
10
+ !/data/litellm
11
+ /data/litellm/*
12
+ !data/litellm/config.yaml
13
+
14
+ !data/config.json
backend/.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ .env
3
+ _old
4
+ uploads
5
+ .ipynb_checkpoints
6
+ *.db
7
+ _test
8
+ Pipfile
9
+ !/data
10
+ /data/*
11
+ !/data/litellm
12
+ /data/litellm/*
13
+ !data/litellm/config.yaml
14
+
15
+ !data/config.json
16
+ .webui_secret_key
backend/apps/audio/main.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from fastapi import (
4
+ FastAPI,
5
+ Request,
6
+ Depends,
7
+ HTTPException,
8
+ status,
9
+ UploadFile,
10
+ File,
11
+ Form,
12
+ )
13
+
14
+ from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
15
+
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from faster_whisper import WhisperModel
18
+ from pydantic import BaseModel
19
+
20
+
21
+ import requests
22
+ import hashlib
23
+ from pathlib import Path
24
+ import json
25
+
26
+
27
+ from constants import ERROR_MESSAGES
28
+ from utils.utils import (
29
+ decode_token,
30
+ get_current_user,
31
+ get_verified_user,
32
+ get_admin_user,
33
+ )
34
+ from utils.misc import calculate_sha256
35
+
36
+ from config import (
37
+ SRC_LOG_LEVELS,
38
+ CACHE_DIR,
39
+ UPLOAD_DIR,
40
+ WHISPER_MODEL,
41
+ WHISPER_MODEL_DIR,
42
+ WHISPER_MODEL_AUTO_UPDATE,
43
+ DEVICE_TYPE,
44
+ AUDIO_OPENAI_API_BASE_URL,
45
+ AUDIO_OPENAI_API_KEY,
46
+ AUDIO_OPENAI_API_MODEL,
47
+ AUDIO_OPENAI_API_VOICE,
48
+ AppConfig,
49
+ )
50
+
51
+ log = logging.getLogger(__name__)
52
+ log.setLevel(SRC_LOG_LEVELS["AUDIO"])
53
+
54
+ app = FastAPI()
55
+ app.add_middleware(
56
+ CORSMiddleware,
57
+ allow_origins=["*"],
58
+ allow_credentials=True,
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ )
62
+
63
+ app.state.config = AppConfig()
64
+ app.state.config.OPENAI_API_BASE_URL = AUDIO_OPENAI_API_BASE_URL
65
+ app.state.config.OPENAI_API_KEY = AUDIO_OPENAI_API_KEY
66
+ app.state.config.OPENAI_API_MODEL = AUDIO_OPENAI_API_MODEL
67
+ app.state.config.OPENAI_API_VOICE = AUDIO_OPENAI_API_VOICE
68
+
69
+ # setting device type for whisper model
70
+ whisper_device_type = DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu"
71
+ log.info(f"whisper_device_type: {whisper_device_type}")
72
+
73
+ SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
74
+ SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
75
+
76
+
77
+ class OpenAIConfigUpdateForm(BaseModel):
78
+ url: str
79
+ key: str
80
+ model: str
81
+ speaker: str
82
+
83
+
84
+ @app.get("/config")
85
+ async def get_openai_config(user=Depends(get_admin_user)):
86
+ return {
87
+ "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL,
88
+ "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY,
89
+ "OPENAI_API_MODEL": app.state.config.OPENAI_API_MODEL,
90
+ "OPENAI_API_VOICE": app.state.config.OPENAI_API_VOICE,
91
+ }
92
+
93
+
94
+ @app.post("/config/update")
95
+ async def update_openai_config(
96
+ form_data: OpenAIConfigUpdateForm, user=Depends(get_admin_user)
97
+ ):
98
+ if form_data.key == "":
99
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
100
+
101
+ app.state.config.OPENAI_API_BASE_URL = form_data.url
102
+ app.state.config.OPENAI_API_KEY = form_data.key
103
+ app.state.config.OPENAI_API_MODEL = form_data.model
104
+ app.state.config.OPENAI_API_VOICE = form_data.speaker
105
+
106
+ return {
107
+ "status": True,
108
+ "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL,
109
+ "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY,
110
+ "OPENAI_API_MODEL": app.state.config.OPENAI_API_MODEL,
111
+ "OPENAI_API_VOICE": app.state.config.OPENAI_API_VOICE,
112
+ }
113
+
114
+
115
+ @app.post("/speech")
116
+ async def speech(request: Request, user=Depends(get_verified_user)):
117
+ body = await request.body()
118
+ name = hashlib.sha256(body).hexdigest()
119
+
120
+ file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
121
+ file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
122
+
123
+ # Check if the file already exists in the cache
124
+ if file_path.is_file():
125
+ return FileResponse(file_path)
126
+
127
+ headers = {}
128
+ headers["Authorization"] = f"Bearer {app.state.config.OPENAI_API_KEY}"
129
+ headers["Content-Type"] = "application/json"
130
+
131
+ r = None
132
+ try:
133
+ r = requests.post(
134
+ url=f"{app.state.config.OPENAI_API_BASE_URL}/audio/speech",
135
+ data=body,
136
+ headers=headers,
137
+ stream=True,
138
+ )
139
+
140
+ r.raise_for_status()
141
+
142
+ # Save the streaming content to a file
143
+ with open(file_path, "wb") as f:
144
+ for chunk in r.iter_content(chunk_size=8192):
145
+ f.write(chunk)
146
+
147
+ with open(file_body_path, "w") as f:
148
+ json.dump(json.loads(body.decode("utf-8")), f)
149
+
150
+ # Return the saved file
151
+ return FileResponse(file_path)
152
+
153
+ except Exception as e:
154
+ log.exception(e)
155
+ error_detail = "Open WebUI: Server Connection Error"
156
+ if r is not None:
157
+ try:
158
+ res = r.json()
159
+ if "error" in res:
160
+ error_detail = f"External: {res['error']['message']}"
161
+ except:
162
+ error_detail = f"External: {e}"
163
+
164
+ raise HTTPException(
165
+ status_code=r.status_code if r != None else 500,
166
+ detail=error_detail,
167
+ )
168
+
169
+
170
+ @app.post("/transcriptions")
171
+ def transcribe(
172
+ file: UploadFile = File(...),
173
+ user=Depends(get_current_user),
174
+ ):
175
+ log.info(f"file.content_type: {file.content_type}")
176
+
177
+ if file.content_type not in ["audio/mpeg", "audio/wav"]:
178
+ raise HTTPException(
179
+ status_code=status.HTTP_400_BAD_REQUEST,
180
+ detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
181
+ )
182
+
183
+ try:
184
+ filename = file.filename
185
+ file_path = f"{UPLOAD_DIR}/{filename}"
186
+ contents = file.file.read()
187
+ with open(file_path, "wb") as f:
188
+ f.write(contents)
189
+ f.close()
190
+
191
+ whisper_kwargs = {
192
+ "model_size_or_path": WHISPER_MODEL,
193
+ "device": whisper_device_type,
194
+ "compute_type": "int8",
195
+ "download_root": WHISPER_MODEL_DIR,
196
+ "local_files_only": not WHISPER_MODEL_AUTO_UPDATE,
197
+ }
198
+
199
+ log.debug(f"whisper_kwargs: {whisper_kwargs}")
200
+
201
+ try:
202
+ model = WhisperModel(**whisper_kwargs)
203
+ except:
204
+ log.warning(
205
+ "WhisperModel initialization failed, attempting download with local_files_only=False"
206
+ )
207
+ whisper_kwargs["local_files_only"] = False
208
+ model = WhisperModel(**whisper_kwargs)
209
+
210
+ segments, info = model.transcribe(file_path, beam_size=5)
211
+ log.info(
212
+ "Detected language '%s' with probability %f"
213
+ % (info.language, info.language_probability)
214
+ )
215
+
216
+ transcript = "".join([segment.text for segment in list(segments)])
217
+
218
+ return {"text": transcript.strip()}
219
+
220
+ except Exception as e:
221
+ log.exception(e)
222
+
223
+ raise HTTPException(
224
+ status_code=status.HTTP_400_BAD_REQUEST,
225
+ detail=ERROR_MESSAGES.DEFAULT(e),
226
+ )
backend/apps/images/main.py ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import requests
3
+ from fastapi import (
4
+ FastAPI,
5
+ Request,
6
+ Depends,
7
+ HTTPException,
8
+ status,
9
+ UploadFile,
10
+ File,
11
+ Form,
12
+ )
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from faster_whisper import WhisperModel
15
+
16
+ from constants import ERROR_MESSAGES
17
+ from utils.utils import (
18
+ get_current_user,
19
+ get_admin_user,
20
+ )
21
+
22
+ from apps.images.utils.comfyui import ImageGenerationPayload, comfyui_generate_image
23
+ from utils.misc import calculate_sha256
24
+ from typing import Optional
25
+ from pydantic import BaseModel
26
+ from pathlib import Path
27
+ import mimetypes
28
+ import uuid
29
+ import base64
30
+ import json
31
+ import logging
32
+
33
+ from config import (
34
+ SRC_LOG_LEVELS,
35
+ CACHE_DIR,
36
+ IMAGE_GENERATION_ENGINE,
37
+ ENABLE_IMAGE_GENERATION,
38
+ AUTOMATIC1111_BASE_URL,
39
+ COMFYUI_BASE_URL,
40
+ IMAGES_OPENAI_API_BASE_URL,
41
+ IMAGES_OPENAI_API_KEY,
42
+ IMAGE_GENERATION_MODEL,
43
+ IMAGE_SIZE,
44
+ IMAGE_STEPS,
45
+ AppConfig,
46
+ )
47
+
48
+
49
+ log = logging.getLogger(__name__)
50
+ log.setLevel(SRC_LOG_LEVELS["IMAGES"])
51
+
52
+ IMAGE_CACHE_DIR = Path(CACHE_DIR).joinpath("./image/generations/")
53
+ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
54
+
55
+ app = FastAPI()
56
+ app.add_middleware(
57
+ CORSMiddleware,
58
+ allow_origins=["*"],
59
+ allow_credentials=True,
60
+ allow_methods=["*"],
61
+ allow_headers=["*"],
62
+ )
63
+
64
+ app.state.config = AppConfig()
65
+
66
+ app.state.config.ENGINE = IMAGE_GENERATION_ENGINE
67
+ app.state.config.ENABLED = ENABLE_IMAGE_GENERATION
68
+
69
+ app.state.config.OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
70
+ app.state.config.OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
71
+
72
+ app.state.config.MODEL = IMAGE_GENERATION_MODEL
73
+
74
+
75
+ app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL
76
+ app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL
77
+
78
+
79
+ app.state.config.IMAGE_SIZE = IMAGE_SIZE
80
+ app.state.config.IMAGE_STEPS = IMAGE_STEPS
81
+
82
+
83
+ @app.get("/config")
84
+ async def get_config(request: Request, user=Depends(get_admin_user)):
85
+ return {
86
+ "engine": app.state.config.ENGINE,
87
+ "enabled": app.state.config.ENABLED,
88
+ }
89
+
90
+
91
+ class ConfigUpdateForm(BaseModel):
92
+ engine: str
93
+ enabled: bool
94
+
95
+
96
+ @app.post("/config/update")
97
+ async def update_config(form_data: ConfigUpdateForm, user=Depends(get_admin_user)):
98
+ app.state.config.ENGINE = form_data.engine
99
+ app.state.config.ENABLED = form_data.enabled
100
+ return {
101
+ "engine": app.state.config.ENGINE,
102
+ "enabled": app.state.config.ENABLED,
103
+ }
104
+
105
+
106
+ class EngineUrlUpdateForm(BaseModel):
107
+ AUTOMATIC1111_BASE_URL: Optional[str] = None
108
+ COMFYUI_BASE_URL: Optional[str] = None
109
+
110
+
111
+ @app.get("/url")
112
+ async def get_engine_url(user=Depends(get_admin_user)):
113
+ return {
114
+ "AUTOMATIC1111_BASE_URL": app.state.config.AUTOMATIC1111_BASE_URL,
115
+ "COMFYUI_BASE_URL": app.state.config.COMFYUI_BASE_URL,
116
+ }
117
+
118
+
119
+ @app.post("/url/update")
120
+ async def update_engine_url(
121
+ form_data: EngineUrlUpdateForm, user=Depends(get_admin_user)
122
+ ):
123
+
124
+ if form_data.AUTOMATIC1111_BASE_URL == None:
125
+ app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL
126
+ else:
127
+ url = form_data.AUTOMATIC1111_BASE_URL.strip("/")
128
+ try:
129
+ r = requests.head(url)
130
+ app.state.config.AUTOMATIC1111_BASE_URL = url
131
+ except Exception as e:
132
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
133
+
134
+ if form_data.COMFYUI_BASE_URL == None:
135
+ app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL
136
+ else:
137
+ url = form_data.COMFYUI_BASE_URL.strip("/")
138
+
139
+ try:
140
+ r = requests.head(url)
141
+ app.state.config.COMFYUI_BASE_URL = url
142
+ except Exception as e:
143
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
144
+
145
+ return {
146
+ "AUTOMATIC1111_BASE_URL": app.state.config.AUTOMATIC1111_BASE_URL,
147
+ "COMFYUI_BASE_URL": app.state.config.COMFYUI_BASE_URL,
148
+ "status": True,
149
+ }
150
+
151
+
152
+ class OpenAIConfigUpdateForm(BaseModel):
153
+ url: str
154
+ key: str
155
+
156
+
157
+ @app.get("/openai/config")
158
+ async def get_openai_config(user=Depends(get_admin_user)):
159
+ return {
160
+ "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL,
161
+ "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY,
162
+ }
163
+
164
+
165
+ @app.post("/openai/config/update")
166
+ async def update_openai_config(
167
+ form_data: OpenAIConfigUpdateForm, user=Depends(get_admin_user)
168
+ ):
169
+ if form_data.key == "":
170
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
171
+
172
+ app.state.config.OPENAI_API_BASE_URL = form_data.url
173
+ app.state.config.OPENAI_API_KEY = form_data.key
174
+
175
+ return {
176
+ "status": True,
177
+ "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL,
178
+ "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY,
179
+ }
180
+
181
+
182
+ class ImageSizeUpdateForm(BaseModel):
183
+ size: str
184
+
185
+
186
+ @app.get("/size")
187
+ async def get_image_size(user=Depends(get_admin_user)):
188
+ return {"IMAGE_SIZE": app.state.config.IMAGE_SIZE}
189
+
190
+
191
+ @app.post("/size/update")
192
+ async def update_image_size(
193
+ form_data: ImageSizeUpdateForm, user=Depends(get_admin_user)
194
+ ):
195
+ pattern = r"^\d+x\d+$" # Regular expression pattern
196
+ if re.match(pattern, form_data.size):
197
+ app.state.config.IMAGE_SIZE = form_data.size
198
+ return {
199
+ "IMAGE_SIZE": app.state.config.IMAGE_SIZE,
200
+ "status": True,
201
+ }
202
+ else:
203
+ raise HTTPException(
204
+ status_code=400,
205
+ detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 512x512)."),
206
+ )
207
+
208
+
209
+ class ImageStepsUpdateForm(BaseModel):
210
+ steps: int
211
+
212
+
213
+ @app.get("/steps")
214
+ async def get_image_size(user=Depends(get_admin_user)):
215
+ return {"IMAGE_STEPS": app.state.config.IMAGE_STEPS}
216
+
217
+
218
+ @app.post("/steps/update")
219
+ async def update_image_size(
220
+ form_data: ImageStepsUpdateForm, user=Depends(get_admin_user)
221
+ ):
222
+ if form_data.steps >= 0:
223
+ app.state.config.IMAGE_STEPS = form_data.steps
224
+ return {
225
+ "IMAGE_STEPS": app.state.config.IMAGE_STEPS,
226
+ "status": True,
227
+ }
228
+ else:
229
+ raise HTTPException(
230
+ status_code=400,
231
+ detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 50)."),
232
+ )
233
+
234
+
235
+ @app.get("/models")
236
+ def get_models(user=Depends(get_current_user)):
237
+ try:
238
+ if app.state.config.ENGINE == "openai":
239
+ return [
240
+ {"id": "dall-e-2", "name": "DALL·E 2"},
241
+ {"id": "dall-e-3", "name": "DALL·E 3"},
242
+ ]
243
+ elif app.state.config.ENGINE == "comfyui":
244
+
245
+ r = requests.get(url=f"{app.state.config.COMFYUI_BASE_URL}/object_info")
246
+ info = r.json()
247
+
248
+ return list(
249
+ map(
250
+ lambda model: {"id": model, "name": model},
251
+ info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0],
252
+ )
253
+ )
254
+
255
+ else:
256
+ r = requests.get(
257
+ url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models"
258
+ )
259
+ models = r.json()
260
+ return list(
261
+ map(
262
+ lambda model: {"id": model["title"], "name": model["model_name"]},
263
+ models,
264
+ )
265
+ )
266
+ except Exception as e:
267
+ app.state.config.ENABLED = False
268
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
269
+
270
+
271
+ @app.get("/models/default")
272
+ async def get_default_model(user=Depends(get_admin_user)):
273
+ try:
274
+ if app.state.config.ENGINE == "openai":
275
+ return {
276
+ "model": (
277
+ app.state.config.MODEL if app.state.config.MODEL else "dall-e-2"
278
+ )
279
+ }
280
+ elif app.state.config.ENGINE == "comfyui":
281
+ return {"model": (app.state.config.MODEL if app.state.config.MODEL else "")}
282
+ else:
283
+ r = requests.get(
284
+ url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options"
285
+ )
286
+ options = r.json()
287
+ return {"model": options["sd_model_checkpoint"]}
288
+ except Exception as e:
289
+ app.state.config.ENABLED = False
290
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
291
+
292
+
293
+ class UpdateModelForm(BaseModel):
294
+ model: str
295
+
296
+
297
+ def set_model_handler(model: str):
298
+ if app.state.config.ENGINE in ["openai", "comfyui"]:
299
+ app.state.config.MODEL = model
300
+ return app.state.config.MODEL
301
+ else:
302
+ r = requests.get(
303
+ url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options"
304
+ )
305
+ options = r.json()
306
+
307
+ if model != options["sd_model_checkpoint"]:
308
+ options["sd_model_checkpoint"] = model
309
+ r = requests.post(
310
+ url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
311
+ json=options,
312
+ )
313
+
314
+ return options
315
+
316
+
317
+ @app.post("/models/default/update")
318
+ def update_default_model(
319
+ form_data: UpdateModelForm,
320
+ user=Depends(get_current_user),
321
+ ):
322
+ return set_model_handler(form_data.model)
323
+
324
+
325
+ class GenerateImageForm(BaseModel):
326
+ model: Optional[str] = None
327
+ prompt: str
328
+ n: int = 1
329
+ size: Optional[str] = None
330
+ negative_prompt: Optional[str] = None
331
+
332
+
333
+ def save_b64_image(b64_str):
334
+ try:
335
+ image_id = str(uuid.uuid4())
336
+
337
+ if "," in b64_str:
338
+ header, encoded = b64_str.split(",", 1)
339
+ mime_type = header.split(";")[0]
340
+
341
+ img_data = base64.b64decode(encoded)
342
+ image_format = mimetypes.guess_extension(mime_type)
343
+
344
+ image_filename = f"{image_id}{image_format}"
345
+ file_path = IMAGE_CACHE_DIR / f"{image_filename}"
346
+ with open(file_path, "wb") as f:
347
+ f.write(img_data)
348
+ return image_filename
349
+ else:
350
+ image_filename = f"{image_id}.png"
351
+ file_path = IMAGE_CACHE_DIR.joinpath(image_filename)
352
+
353
+ img_data = base64.b64decode(b64_str)
354
+
355
+ # Write the image data to a file
356
+ with open(file_path, "wb") as f:
357
+ f.write(img_data)
358
+ return image_filename
359
+
360
+ except Exception as e:
361
+ log.exception(f"Error saving image: {e}")
362
+ return None
363
+
364
+
365
+ def save_url_image(url):
366
+ image_id = str(uuid.uuid4())
367
+ try:
368
+ r = requests.get(url)
369
+ r.raise_for_status()
370
+ if r.headers["content-type"].split("/")[0] == "image":
371
+
372
+ mime_type = r.headers["content-type"]
373
+ image_format = mimetypes.guess_extension(mime_type)
374
+
375
+ if not image_format:
376
+ raise ValueError("Could not determine image type from MIME type")
377
+
378
+ image_filename = f"{image_id}{image_format}"
379
+
380
+ file_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}")
381
+ with open(file_path, "wb") as image_file:
382
+ for chunk in r.iter_content(chunk_size=8192):
383
+ image_file.write(chunk)
384
+ return image_filename
385
+ else:
386
+ log.error(f"Url does not point to an image.")
387
+ return None
388
+
389
+ except Exception as e:
390
+ log.exception(f"Error saving image: {e}")
391
+ return None
392
+
393
+
394
+ @app.post("/generations")
395
+ def generate_image(
396
+ form_data: GenerateImageForm,
397
+ user=Depends(get_current_user),
398
+ ):
399
+
400
+ width, height = tuple(map(int, app.state.config.IMAGE_SIZE.split("x")))
401
+
402
+ r = None
403
+ try:
404
+ if app.state.config.ENGINE == "openai":
405
+
406
+ headers = {}
407
+ headers["Authorization"] = f"Bearer {app.state.config.OPENAI_API_KEY}"
408
+ headers["Content-Type"] = "application/json"
409
+
410
+ data = {
411
+ "model": (
412
+ app.state.config.MODEL
413
+ if app.state.config.MODEL != ""
414
+ else "dall-e-2"
415
+ ),
416
+ "prompt": form_data.prompt,
417
+ "n": form_data.n,
418
+ "size": (
419
+ form_data.size if form_data.size else app.state.config.IMAGE_SIZE
420
+ ),
421
+ "response_format": "b64_json",
422
+ }
423
+
424
+ r = requests.post(
425
+ url=f"{app.state.config.OPENAI_API_BASE_URL}/images/generations",
426
+ json=data,
427
+ headers=headers,
428
+ )
429
+
430
+ r.raise_for_status()
431
+ res = r.json()
432
+
433
+ images = []
434
+
435
+ for image in res["data"]:
436
+ image_filename = save_b64_image(image["b64_json"])
437
+ images.append({"url": f"/cache/image/generations/{image_filename}"})
438
+ file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
439
+
440
+ with open(file_body_path, "w") as f:
441
+ json.dump(data, f)
442
+
443
+ return images
444
+
445
+ elif app.state.config.ENGINE == "comfyui":
446
+
447
+ data = {
448
+ "prompt": form_data.prompt,
449
+ "width": width,
450
+ "height": height,
451
+ "n": form_data.n,
452
+ }
453
+
454
+ if app.state.config.IMAGE_STEPS is not None:
455
+ data["steps"] = app.state.config.IMAGE_STEPS
456
+
457
+ if form_data.negative_prompt is not None:
458
+ data["negative_prompt"] = form_data.negative_prompt
459
+
460
+ data = ImageGenerationPayload(**data)
461
+
462
+ res = comfyui_generate_image(
463
+ app.state.config.MODEL,
464
+ data,
465
+ user.id,
466
+ app.state.config.COMFYUI_BASE_URL,
467
+ )
468
+ log.debug(f"res: {res}")
469
+
470
+ images = []
471
+
472
+ for image in res["data"]:
473
+ image_filename = save_url_image(image["url"])
474
+ images.append({"url": f"/cache/image/generations/{image_filename}"})
475
+ file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
476
+
477
+ with open(file_body_path, "w") as f:
478
+ json.dump(data.model_dump(exclude_none=True), f)
479
+
480
+ log.debug(f"images: {images}")
481
+ return images
482
+ else:
483
+ if form_data.model:
484
+ set_model_handler(form_data.model)
485
+
486
+ data = {
487
+ "prompt": form_data.prompt,
488
+ "batch_size": form_data.n,
489
+ "width": width,
490
+ "height": height,
491
+ }
492
+
493
+ if app.state.config.IMAGE_STEPS is not None:
494
+ data["steps"] = app.state.config.IMAGE_STEPS
495
+
496
+ if form_data.negative_prompt is not None:
497
+ data["negative_prompt"] = form_data.negative_prompt
498
+
499
+ r = requests.post(
500
+ url=f"{app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img",
501
+ json=data,
502
+ )
503
+
504
+ res = r.json()
505
+
506
+ log.debug(f"res: {res}")
507
+
508
+ images = []
509
+
510
+ for image in res["images"]:
511
+ image_filename = save_b64_image(image)
512
+ images.append({"url": f"/cache/image/generations/{image_filename}"})
513
+ file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
514
+
515
+ with open(file_body_path, "w") as f:
516
+ json.dump({**data, "info": res["info"]}, f)
517
+
518
+ return images
519
+
520
+ except Exception as e:
521
+ error = e
522
+
523
+ if r != None:
524
+ data = r.json()
525
+ if "error" in data:
526
+ error = data["error"]["message"]
527
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))
backend/apps/images/utils/comfyui.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import websocket # NOTE: websocket-client (https://github.com/websocket-client/websocket-client)
2
+ import uuid
3
+ import json
4
+ import urllib.request
5
+ import urllib.parse
6
+ import random
7
+ import logging
8
+
9
+ from config import SRC_LOG_LEVELS
10
+
11
+ log = logging.getLogger(__name__)
12
+ log.setLevel(SRC_LOG_LEVELS["COMFYUI"])
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from typing import Optional
17
+
18
+ COMFYUI_DEFAULT_PROMPT = """
19
+ {
20
+ "3": {
21
+ "inputs": {
22
+ "seed": 0,
23
+ "steps": 20,
24
+ "cfg": 8,
25
+ "sampler_name": "euler",
26
+ "scheduler": "normal",
27
+ "denoise": 1,
28
+ "model": [
29
+ "4",
30
+ 0
31
+ ],
32
+ "positive": [
33
+ "6",
34
+ 0
35
+ ],
36
+ "negative": [
37
+ "7",
38
+ 0
39
+ ],
40
+ "latent_image": [
41
+ "5",
42
+ 0
43
+ ]
44
+ },
45
+ "class_type": "KSampler",
46
+ "_meta": {
47
+ "title": "KSampler"
48
+ }
49
+ },
50
+ "4": {
51
+ "inputs": {
52
+ "ckpt_name": "model.safetensors"
53
+ },
54
+ "class_type": "CheckpointLoaderSimple",
55
+ "_meta": {
56
+ "title": "Load Checkpoint"
57
+ }
58
+ },
59
+ "5": {
60
+ "inputs": {
61
+ "width": 512,
62
+ "height": 512,
63
+ "batch_size": 1
64
+ },
65
+ "class_type": "EmptyLatentImage",
66
+ "_meta": {
67
+ "title": "Empty Latent Image"
68
+ }
69
+ },
70
+ "6": {
71
+ "inputs": {
72
+ "text": "Prompt",
73
+ "clip": [
74
+ "4",
75
+ 1
76
+ ]
77
+ },
78
+ "class_type": "CLIPTextEncode",
79
+ "_meta": {
80
+ "title": "CLIP Text Encode (Prompt)"
81
+ }
82
+ },
83
+ "7": {
84
+ "inputs": {
85
+ "text": "Negative Prompt",
86
+ "clip": [
87
+ "4",
88
+ 1
89
+ ]
90
+ },
91
+ "class_type": "CLIPTextEncode",
92
+ "_meta": {
93
+ "title": "CLIP Text Encode (Prompt)"
94
+ }
95
+ },
96
+ "8": {
97
+ "inputs": {
98
+ "samples": [
99
+ "3",
100
+ 0
101
+ ],
102
+ "vae": [
103
+ "4",
104
+ 2
105
+ ]
106
+ },
107
+ "class_type": "VAEDecode",
108
+ "_meta": {
109
+ "title": "VAE Decode"
110
+ }
111
+ },
112
+ "9": {
113
+ "inputs": {
114
+ "filename_prefix": "ComfyUI",
115
+ "images": [
116
+ "8",
117
+ 0
118
+ ]
119
+ },
120
+ "class_type": "SaveImage",
121
+ "_meta": {
122
+ "title": "Save Image"
123
+ }
124
+ }
125
+ }
126
+ """
127
+
128
+
129
+ def queue_prompt(prompt, client_id, base_url):
130
+ log.info("queue_prompt")
131
+ p = {"prompt": prompt, "client_id": client_id}
132
+ data = json.dumps(p).encode("utf-8")
133
+ req = urllib.request.Request(f"{base_url}/prompt", data=data)
134
+ return json.loads(urllib.request.urlopen(req).read())
135
+
136
+
137
+ def get_image(filename, subfolder, folder_type, base_url):
138
+ log.info("get_image")
139
+ data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
140
+ url_values = urllib.parse.urlencode(data)
141
+ with urllib.request.urlopen(f"{base_url}/view?{url_values}") as response:
142
+ return response.read()
143
+
144
+
145
+ def get_image_url(filename, subfolder, folder_type, base_url):
146
+ log.info("get_image")
147
+ data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
148
+ url_values = urllib.parse.urlencode(data)
149
+ return f"{base_url}/view?{url_values}"
150
+
151
+
152
+ def get_history(prompt_id, base_url):
153
+ log.info("get_history")
154
+ with urllib.request.urlopen(f"{base_url}/history/{prompt_id}") as response:
155
+ return json.loads(response.read())
156
+
157
+
158
+ def get_images(ws, prompt, client_id, base_url):
159
+ prompt_id = queue_prompt(prompt, client_id, base_url)["prompt_id"]
160
+ output_images = []
161
+ while True:
162
+ out = ws.recv()
163
+ if isinstance(out, str):
164
+ message = json.loads(out)
165
+ if message["type"] == "executing":
166
+ data = message["data"]
167
+ if data["node"] is None and data["prompt_id"] == prompt_id:
168
+ break # Execution is done
169
+ else:
170
+ continue # previews are binary data
171
+
172
+ history = get_history(prompt_id, base_url)[prompt_id]
173
+ for o in history["outputs"]:
174
+ for node_id in history["outputs"]:
175
+ node_output = history["outputs"][node_id]
176
+ if "images" in node_output:
177
+ for image in node_output["images"]:
178
+ url = get_image_url(
179
+ image["filename"], image["subfolder"], image["type"], base_url
180
+ )
181
+ output_images.append({"url": url})
182
+ return {"data": output_images}
183
+
184
+
185
+ class ImageGenerationPayload(BaseModel):
186
+ prompt: str
187
+ negative_prompt: Optional[str] = ""
188
+ steps: Optional[int] = None
189
+ seed: Optional[int] = None
190
+ width: int
191
+ height: int
192
+ n: int = 1
193
+
194
+
195
+ def comfyui_generate_image(
196
+ model: str, payload: ImageGenerationPayload, client_id, base_url
197
+ ):
198
+ ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
199
+
200
+ comfyui_prompt = json.loads(COMFYUI_DEFAULT_PROMPT)
201
+
202
+ comfyui_prompt["4"]["inputs"]["ckpt_name"] = model
203
+ comfyui_prompt["5"]["inputs"]["batch_size"] = payload.n
204
+ comfyui_prompt["5"]["inputs"]["width"] = payload.width
205
+ comfyui_prompt["5"]["inputs"]["height"] = payload.height
206
+
207
+ # set the text prompt for our positive CLIPTextEncode
208
+ comfyui_prompt["6"]["inputs"]["text"] = payload.prompt
209
+ comfyui_prompt["7"]["inputs"]["text"] = payload.negative_prompt
210
+
211
+ if payload.steps:
212
+ comfyui_prompt["3"]["inputs"]["steps"] = payload.steps
213
+
214
+ comfyui_prompt["3"]["inputs"]["seed"] = (
215
+ payload.seed if payload.seed else random.randint(0, 18446744073709551614)
216
+ )
217
+
218
+ try:
219
+ ws = websocket.WebSocket()
220
+ ws.connect(f"{ws_url}/ws?clientId={client_id}")
221
+ log.info("WebSocket connection established.")
222
+ except Exception as e:
223
+ log.exception(f"Failed to connect to WebSocket server: {e}")
224
+ return None
225
+
226
+ try:
227
+ images = get_images(ws, comfyui_prompt, client_id, base_url)
228
+ except Exception as e:
229
+ log.exception(f"Error while receiving images: {e}")
230
+ images = None
231
+
232
+ ws.close()
233
+
234
+ return images
backend/apps/litellm/main.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from contextlib import asynccontextmanager
3
+
4
+ from fastapi import FastAPI, Depends, HTTPException
5
+ from fastapi.routing import APIRoute
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+
8
+ import logging
9
+ from fastapi import FastAPI, Request, Depends, status, Response
10
+ from fastapi.responses import JSONResponse
11
+
12
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
13
+ from starlette.responses import StreamingResponse
14
+ import json
15
+ import time
16
+ import requests
17
+
18
+ from pydantic import BaseModel, ConfigDict
19
+ from typing import Optional, List
20
+
21
+ from utils.utils import get_verified_user, get_current_user, get_admin_user
22
+ from config import SRC_LOG_LEVELS, ENV
23
+ from constants import MESSAGES
24
+
25
+ import os
26
+
27
+ log = logging.getLogger(__name__)
28
+ log.setLevel(SRC_LOG_LEVELS["LITELLM"])
29
+
30
+
31
+ from config import (
32
+ ENABLE_LITELLM,
33
+ ENABLE_MODEL_FILTER,
34
+ MODEL_FILTER_LIST,
35
+ DATA_DIR,
36
+ LITELLM_PROXY_PORT,
37
+ LITELLM_PROXY_HOST,
38
+ )
39
+
40
+ import warnings
41
+
42
+ warnings.simplefilter("ignore")
43
+
44
+ from litellm.utils import get_llm_provider
45
+
46
+ import asyncio
47
+ import subprocess
48
+ import yaml
49
+
50
+
51
+ @asynccontextmanager
52
+ async def lifespan(app: FastAPI):
53
+ log.info("startup_event")
54
+ # TODO: Check config.yaml file and create one
55
+ asyncio.create_task(start_litellm_background())
56
+ yield
57
+
58
+
59
+ app = FastAPI(lifespan=lifespan)
60
+
61
+ origins = ["*"]
62
+
63
+ app.add_middleware(
64
+ CORSMiddleware,
65
+ allow_origins=origins,
66
+ allow_credentials=True,
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+
72
+ LITELLM_CONFIG_DIR = f"{DATA_DIR}/litellm/config.yaml"
73
+
74
+ with open(LITELLM_CONFIG_DIR, "r") as file:
75
+ litellm_config = yaml.safe_load(file)
76
+
77
+
78
+ app.state.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER.value
79
+ app.state.MODEL_FILTER_LIST = MODEL_FILTER_LIST.value
80
+
81
+
82
+ app.state.ENABLE = ENABLE_LITELLM
83
+ app.state.CONFIG = litellm_config
84
+
85
+ # Global variable to store the subprocess reference
86
+ background_process = None
87
+
88
+ CONFLICT_ENV_VARS = [
89
+ # Uvicorn uses PORT, so LiteLLM might use it as well
90
+ "PORT",
91
+ # LiteLLM uses DATABASE_URL for Prisma connections
92
+ "DATABASE_URL",
93
+ ]
94
+
95
+
96
+ async def run_background_process(command):
97
+ global background_process
98
+ log.info("run_background_process")
99
+
100
+ try:
101
+ # Log the command to be executed
102
+ log.info(f"Executing command: {command}")
103
+ # Filter environment variables known to conflict with litellm
104
+ env = {k: v for k, v in os.environ.items() if k not in CONFLICT_ENV_VARS}
105
+ # Execute the command and create a subprocess
106
+ process = await asyncio.create_subprocess_exec(
107
+ *command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env
108
+ )
109
+ background_process = process
110
+ log.info("Subprocess started successfully.")
111
+
112
+ # Capture STDERR for debugging purposes
113
+ stderr_output = await process.stderr.read()
114
+ stderr_text = stderr_output.decode().strip()
115
+ if stderr_text:
116
+ log.info(f"Subprocess STDERR: {stderr_text}")
117
+
118
+ # log.info output line by line
119
+ async for line in process.stdout:
120
+ log.info(line.decode().strip())
121
+
122
+ # Wait for the process to finish
123
+ returncode = await process.wait()
124
+ log.info(f"Subprocess exited with return code {returncode}")
125
+ except Exception as e:
126
+ log.error(f"Failed to start subprocess: {e}")
127
+ raise # Optionally re-raise the exception if you want it to propagate
128
+
129
+
130
+ async def start_litellm_background():
131
+ log.info("start_litellm_background")
132
+ # Command to run in the background
133
+ command = [
134
+ "litellm",
135
+ "--port",
136
+ str(LITELLM_PROXY_PORT),
137
+ "--host",
138
+ LITELLM_PROXY_HOST,
139
+ "--telemetry",
140
+ "False",
141
+ "--config",
142
+ LITELLM_CONFIG_DIR,
143
+ ]
144
+
145
+ await run_background_process(command)
146
+
147
+
148
+ async def shutdown_litellm_background():
149
+ log.info("shutdown_litellm_background")
150
+ global background_process
151
+ if background_process:
152
+ background_process.terminate()
153
+ await background_process.wait() # Ensure the process has terminated
154
+ log.info("Subprocess terminated")
155
+ background_process = None
156
+
157
+
158
+ @app.get("/")
159
+ async def get_status():
160
+ return {"status": True}
161
+
162
+
163
+ async def restart_litellm():
164
+ """
165
+ Endpoint to restart the litellm background service.
166
+ """
167
+ log.info("Requested restart of litellm service.")
168
+ try:
169
+ # Shut down the existing process if it is running
170
+ await shutdown_litellm_background()
171
+ log.info("litellm service shutdown complete.")
172
+
173
+ # Restart the background service
174
+
175
+ asyncio.create_task(start_litellm_background())
176
+ log.info("litellm service restart complete.")
177
+
178
+ return {
179
+ "status": "success",
180
+ "message": "litellm service restarted successfully.",
181
+ }
182
+ except Exception as e:
183
+ log.info(f"Error restarting litellm service: {e}")
184
+ raise HTTPException(
185
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
186
+ )
187
+
188
+
189
+ @app.get("/restart")
190
+ async def restart_litellm_handler(user=Depends(get_admin_user)):
191
+ return await restart_litellm()
192
+
193
+
194
+ @app.get("/config")
195
+ async def get_config(user=Depends(get_admin_user)):
196
+ return app.state.CONFIG
197
+
198
+
199
+ class LiteLLMConfigForm(BaseModel):
200
+ general_settings: Optional[dict] = None
201
+ litellm_settings: Optional[dict] = None
202
+ model_list: Optional[List[dict]] = None
203
+ router_settings: Optional[dict] = None
204
+
205
+ model_config = ConfigDict(protected_namespaces=())
206
+
207
+
208
+ @app.post("/config/update")
209
+ async def update_config(form_data: LiteLLMConfigForm, user=Depends(get_admin_user)):
210
+ app.state.CONFIG = form_data.model_dump(exclude_none=True)
211
+
212
+ with open(LITELLM_CONFIG_DIR, "w") as file:
213
+ yaml.dump(app.state.CONFIG, file)
214
+
215
+ await restart_litellm()
216
+ return app.state.CONFIG
217
+
218
+
219
+ @app.get("/models")
220
+ @app.get("/v1/models")
221
+ async def get_models(user=Depends(get_current_user)):
222
+
223
+ if app.state.ENABLE:
224
+ while not background_process:
225
+ await asyncio.sleep(0.1)
226
+
227
+ url = f"http://localhost:{LITELLM_PROXY_PORT}/v1"
228
+ r = None
229
+ try:
230
+ r = requests.request(method="GET", url=f"{url}/models")
231
+ r.raise_for_status()
232
+
233
+ data = r.json()
234
+
235
+ if app.state.ENABLE_MODEL_FILTER:
236
+ if user and user.role == "user":
237
+ data["data"] = list(
238
+ filter(
239
+ lambda model: model["id"] in app.state.MODEL_FILTER_LIST,
240
+ data["data"],
241
+ )
242
+ )
243
+
244
+ return data
245
+ except Exception as e:
246
+
247
+ log.exception(e)
248
+ error_detail = "Open WebUI: Server Connection Error"
249
+ if r is not None:
250
+ try:
251
+ res = r.json()
252
+ if "error" in res:
253
+ error_detail = f"External: {res['error']}"
254
+ except:
255
+ error_detail = f"External: {e}"
256
+
257
+ return {
258
+ "data": [
259
+ {
260
+ "id": model["model_name"],
261
+ "object": "model",
262
+ "created": int(time.time()),
263
+ "owned_by": "openai",
264
+ }
265
+ for model in app.state.CONFIG["model_list"]
266
+ ],
267
+ "object": "list",
268
+ }
269
+ else:
270
+ return {
271
+ "data": [],
272
+ "object": "list",
273
+ }
274
+
275
+
276
+ @app.get("/model/info")
277
+ async def get_model_list(user=Depends(get_admin_user)):
278
+ return {"data": app.state.CONFIG["model_list"]}
279
+
280
+
281
+ class AddLiteLLMModelForm(BaseModel):
282
+ model_name: str
283
+ litellm_params: dict
284
+
285
+ model_config = ConfigDict(protected_namespaces=())
286
+
287
+
288
+ @app.post("/model/new")
289
+ async def add_model_to_config(
290
+ form_data: AddLiteLLMModelForm, user=Depends(get_admin_user)
291
+ ):
292
+ try:
293
+ get_llm_provider(model=form_data.model_name)
294
+ app.state.CONFIG["model_list"].append(form_data.model_dump())
295
+
296
+ with open(LITELLM_CONFIG_DIR, "w") as file:
297
+ yaml.dump(app.state.CONFIG, file)
298
+
299
+ await restart_litellm()
300
+
301
+ return {"message": MESSAGES.MODEL_ADDED(form_data.model_name)}
302
+ except Exception as e:
303
+ print(e)
304
+ raise HTTPException(
305
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
306
+ )
307
+
308
+
309
+ class DeleteLiteLLMModelForm(BaseModel):
310
+ id: str
311
+
312
+
313
+ @app.post("/model/delete")
314
+ async def delete_model_from_config(
315
+ form_data: DeleteLiteLLMModelForm, user=Depends(get_admin_user)
316
+ ):
317
+ app.state.CONFIG["model_list"] = [
318
+ model
319
+ for model in app.state.CONFIG["model_list"]
320
+ if model["model_name"] != form_data.id
321
+ ]
322
+
323
+ with open(LITELLM_CONFIG_DIR, "w") as file:
324
+ yaml.dump(app.state.CONFIG, file)
325
+
326
+ await restart_litellm()
327
+
328
+ return {"message": MESSAGES.MODEL_DELETED(form_data.id)}
329
+
330
+
331
+ @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
332
+ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
333
+ body = await request.body()
334
+
335
+ url = f"http://localhost:{LITELLM_PROXY_PORT}"
336
+
337
+ target_url = f"{url}/{path}"
338
+
339
+ headers = {}
340
+ # headers["Authorization"] = f"Bearer {key}"
341
+ headers["Content-Type"] = "application/json"
342
+
343
+ r = None
344
+
345
+ try:
346
+ r = requests.request(
347
+ method=request.method,
348
+ url=target_url,
349
+ data=body,
350
+ headers=headers,
351
+ stream=True,
352
+ )
353
+
354
+ r.raise_for_status()
355
+
356
+ # Check if response is SSE
357
+ if "text/event-stream" in r.headers.get("Content-Type", ""):
358
+ return StreamingResponse(
359
+ r.iter_content(chunk_size=8192),
360
+ status_code=r.status_code,
361
+ headers=dict(r.headers),
362
+ )
363
+ else:
364
+ response_data = r.json()
365
+ return response_data
366
+ except Exception as e:
367
+ log.exception(e)
368
+ error_detail = "Open WebUI: Server Connection Error"
369
+ if r is not None:
370
+ try:
371
+ res = r.json()
372
+ if "error" in res:
373
+ error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
374
+ except:
375
+ error_detail = f"External: {e}"
376
+
377
+ raise HTTPException(
378
+ status_code=r.status_code if r else 500, detail=error_detail
379
+ )
backend/apps/ollama/main.py ADDED
@@ -0,0 +1,1420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import (
2
+ FastAPI,
3
+ Request,
4
+ Response,
5
+ HTTPException,
6
+ Depends,
7
+ status,
8
+ UploadFile,
9
+ File,
10
+ BackgroundTasks,
11
+ )
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import StreamingResponse
14
+ from fastapi.concurrency import run_in_threadpool
15
+
16
+ from pydantic import BaseModel, ConfigDict
17
+
18
+ import os
19
+ import re
20
+ import copy
21
+ import random
22
+ import requests
23
+ import json
24
+ import uuid
25
+ import aiohttp
26
+ import asyncio
27
+ import logging
28
+ import time
29
+ from urllib.parse import urlparse
30
+ from typing import Optional, List, Union
31
+
32
+
33
+ from apps.web.models.users import Users
34
+ from constants import ERROR_MESSAGES
35
+ from utils.utils import (
36
+ decode_token,
37
+ get_current_user,
38
+ get_verified_user,
39
+ get_admin_user,
40
+ )
41
+
42
+
43
+ from config import (
44
+ SRC_LOG_LEVELS,
45
+ OLLAMA_BASE_URLS,
46
+ ENABLE_MODEL_FILTER,
47
+ MODEL_FILTER_LIST,
48
+ UPLOAD_DIR,
49
+ AppConfig,
50
+ )
51
+ from utils.misc import calculate_sha256
52
+
53
+ log = logging.getLogger(__name__)
54
+ log.setLevel(SRC_LOG_LEVELS["OLLAMA"])
55
+
56
+ app = FastAPI()
57
+ app.add_middleware(
58
+ CORSMiddleware,
59
+ allow_origins=["*"],
60
+ allow_credentials=True,
61
+ allow_methods=["*"],
62
+ allow_headers=["*"],
63
+ )
64
+
65
+ app.state.config = AppConfig()
66
+
67
+ app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER
68
+ app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST
69
+
70
+ app.state.config.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS
71
+ app.state.MODELS = {}
72
+
73
+
74
+ REQUEST_POOL = []
75
+
76
+
77
+ # TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances.
78
+ # Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin,
79
+ # least connections, or least response time for better resource utilization and performance optimization.
80
+
81
+
82
+ @app.middleware("http")
83
+ async def check_url(request: Request, call_next):
84
+ if len(app.state.MODELS) == 0:
85
+ await get_all_models()
86
+ else:
87
+ pass
88
+
89
+ response = await call_next(request)
90
+ return response
91
+
92
+
93
+ @app.head("/")
94
+ @app.get("/")
95
+ async def get_status():
96
+ return {"status": True}
97
+
98
+
99
+ @app.get("/urls")
100
+ async def get_ollama_api_urls(user=Depends(get_admin_user)):
101
+ return {"OLLAMA_BASE_URLS": app.state.config.OLLAMA_BASE_URLS}
102
+
103
+
104
+ class UrlUpdateForm(BaseModel):
105
+ urls: List[str]
106
+
107
+
108
+ @app.post("/urls/update")
109
+ async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin_user)):
110
+ app.state.config.OLLAMA_BASE_URLS = form_data.urls
111
+
112
+ log.info(f"app.state.config.OLLAMA_BASE_URLS: {app.state.config.OLLAMA_BASE_URLS}")
113
+ return {"OLLAMA_BASE_URLS": app.state.config.OLLAMA_BASE_URLS}
114
+
115
+
116
+ @app.get("/cancel/{request_id}")
117
+ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user)):
118
+ if user:
119
+ if request_id in REQUEST_POOL:
120
+ REQUEST_POOL.remove(request_id)
121
+ return True
122
+ else:
123
+ raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
124
+
125
+
126
+ async def fetch_url(url):
127
+ timeout = aiohttp.ClientTimeout(total=5)
128
+ try:
129
+ async with aiohttp.ClientSession(timeout=timeout) as session:
130
+ async with session.get(url) as response:
131
+ return await response.json()
132
+ except Exception as e:
133
+ # Handle connection error here
134
+ log.error(f"Connection error: {e}")
135
+ return None
136
+
137
+
138
+ def merge_models_lists(model_lists):
139
+ merged_models = {}
140
+
141
+ for idx, model_list in enumerate(model_lists):
142
+ if model_list is not None:
143
+ for model in model_list:
144
+ digest = model["digest"]
145
+ if digest not in merged_models:
146
+ model["urls"] = [idx]
147
+ merged_models[digest] = model
148
+ else:
149
+ merged_models[digest]["urls"].append(idx)
150
+
151
+ return list(merged_models.values())
152
+
153
+
154
+ # user=Depends(get_current_user)
155
+
156
+
157
+ async def get_all_models():
158
+ log.info("get_all_models()")
159
+ tasks = [fetch_url(f"{url}/api/tags") for url in app.state.config.OLLAMA_BASE_URLS]
160
+ responses = await asyncio.gather(*tasks)
161
+
162
+ models = {
163
+ "models": merge_models_lists(
164
+ map(lambda response: response["models"] if response else None, responses)
165
+ )
166
+ }
167
+
168
+ app.state.MODELS = {model["model"]: model for model in models["models"]}
169
+
170
+ return models
171
+
172
+
173
+ @app.get("/api/tags")
174
+ @app.get("/api/tags/{url_idx}")
175
+ async def get_ollama_tags(
176
+ url_idx: Optional[int] = None, user=Depends(get_verified_user)
177
+ ):
178
+ if url_idx == None:
179
+ models = await get_all_models()
180
+
181
+ if app.state.config.ENABLE_MODEL_FILTER:
182
+ if user.role == "user":
183
+ models["models"] = list(
184
+ filter(
185
+ lambda model: model["name"]
186
+ in app.state.config.MODEL_FILTER_LIST,
187
+ models["models"],
188
+ )
189
+ )
190
+ return models
191
+ return models
192
+ else:
193
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
194
+ try:
195
+ r = requests.request(method="GET", url=f"{url}/api/tags")
196
+ r.raise_for_status()
197
+
198
+ return r.json()
199
+ except Exception as e:
200
+ log.exception(e)
201
+ error_detail = "Open WebUI: Server Connection Error"
202
+ if r is not None:
203
+ try:
204
+ res = r.json()
205
+ if "error" in res:
206
+ error_detail = f"Ollama: {res['error']}"
207
+ except:
208
+ error_detail = f"Ollama: {e}"
209
+
210
+ raise HTTPException(
211
+ status_code=r.status_code if r else 500,
212
+ detail=error_detail,
213
+ )
214
+
215
+
216
+ @app.get("/api/version")
217
+ @app.get("/api/version/{url_idx}")
218
+ async def get_ollama_versions(url_idx: Optional[int] = None):
219
+
220
+ if url_idx == None:
221
+
222
+ # returns lowest version
223
+ tasks = [
224
+ fetch_url(f"{url}/api/version") for url in app.state.config.OLLAMA_BASE_URLS
225
+ ]
226
+ responses = await asyncio.gather(*tasks)
227
+ responses = list(filter(lambda x: x is not None, responses))
228
+
229
+ if len(responses) > 0:
230
+ lowest_version = min(
231
+ responses,
232
+ key=lambda x: tuple(
233
+ map(int, re.sub(r"^v|-.*", "", x["version"]).split("."))
234
+ ),
235
+ )
236
+
237
+ return {"version": lowest_version["version"]}
238
+ else:
239
+ raise HTTPException(
240
+ status_code=500,
241
+ detail=ERROR_MESSAGES.OLLAMA_NOT_FOUND,
242
+ )
243
+ else:
244
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
245
+ try:
246
+ r = requests.request(method="GET", url=f"{url}/api/version")
247
+ r.raise_for_status()
248
+
249
+ return r.json()
250
+ except Exception as e:
251
+ log.exception(e)
252
+ error_detail = "Open WebUI: Server Connection Error"
253
+ if r is not None:
254
+ try:
255
+ res = r.json()
256
+ if "error" in res:
257
+ error_detail = f"Ollama: {res['error']}"
258
+ except:
259
+ error_detail = f"Ollama: {e}"
260
+
261
+ raise HTTPException(
262
+ status_code=r.status_code if r else 500,
263
+ detail=error_detail,
264
+ )
265
+
266
+
267
+ class ModelNameForm(BaseModel):
268
+ name: str
269
+
270
+
271
+ @app.post("/api/pull")
272
+ @app.post("/api/pull/{url_idx}")
273
+ async def pull_model(
274
+ form_data: ModelNameForm, url_idx: int = 0, user=Depends(get_admin_user)
275
+ ):
276
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
277
+ log.info(f"url: {url}")
278
+
279
+ r = None
280
+
281
+ def get_request():
282
+ nonlocal url
283
+ nonlocal r
284
+
285
+ request_id = str(uuid.uuid4())
286
+ try:
287
+ REQUEST_POOL.append(request_id)
288
+
289
+ def stream_content():
290
+ try:
291
+ yield json.dumps({"id": request_id, "done": False}) + "\n"
292
+
293
+ for chunk in r.iter_content(chunk_size=8192):
294
+ if request_id in REQUEST_POOL:
295
+ yield chunk
296
+ else:
297
+ log.warning("User: canceled request")
298
+ break
299
+ finally:
300
+ if hasattr(r, "close"):
301
+ r.close()
302
+ if request_id in REQUEST_POOL:
303
+ REQUEST_POOL.remove(request_id)
304
+
305
+ r = requests.request(
306
+ method="POST",
307
+ url=f"{url}/api/pull",
308
+ data=form_data.model_dump_json(exclude_none=True).encode(),
309
+ stream=True,
310
+ )
311
+
312
+ r.raise_for_status()
313
+
314
+ return StreamingResponse(
315
+ stream_content(),
316
+ status_code=r.status_code,
317
+ headers=dict(r.headers),
318
+ )
319
+ except Exception as e:
320
+ raise e
321
+
322
+ try:
323
+ return await run_in_threadpool(get_request)
324
+
325
+ except Exception as e:
326
+ log.exception(e)
327
+ error_detail = "Open WebUI: Server Connection Error"
328
+ if r is not None:
329
+ try:
330
+ res = r.json()
331
+ if "error" in res:
332
+ error_detail = f"Ollama: {res['error']}"
333
+ except:
334
+ error_detail = f"Ollama: {e}"
335
+
336
+ raise HTTPException(
337
+ status_code=r.status_code if r else 500,
338
+ detail=error_detail,
339
+ )
340
+
341
+
342
+ class PushModelForm(BaseModel):
343
+ name: str
344
+ insecure: Optional[bool] = None
345
+ stream: Optional[bool] = None
346
+
347
+
348
+ @app.delete("/api/push")
349
+ @app.delete("/api/push/{url_idx}")
350
+ async def push_model(
351
+ form_data: PushModelForm,
352
+ url_idx: Optional[int] = None,
353
+ user=Depends(get_admin_user),
354
+ ):
355
+ if url_idx == None:
356
+ if form_data.name in app.state.MODELS:
357
+ url_idx = app.state.MODELS[form_data.name]["urls"][0]
358
+ else:
359
+ raise HTTPException(
360
+ status_code=400,
361
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
362
+ )
363
+
364
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
365
+ log.debug(f"url: {url}")
366
+
367
+ r = None
368
+
369
+ def get_request():
370
+ nonlocal url
371
+ nonlocal r
372
+ try:
373
+
374
+ def stream_content():
375
+ for chunk in r.iter_content(chunk_size=8192):
376
+ yield chunk
377
+
378
+ r = requests.request(
379
+ method="POST",
380
+ url=f"{url}/api/push",
381
+ data=form_data.model_dump_json(exclude_none=True).encode(),
382
+ )
383
+
384
+ r.raise_for_status()
385
+
386
+ return StreamingResponse(
387
+ stream_content(),
388
+ status_code=r.status_code,
389
+ headers=dict(r.headers),
390
+ )
391
+ except Exception as e:
392
+ raise e
393
+
394
+ try:
395
+ return await run_in_threadpool(get_request)
396
+ except Exception as e:
397
+ log.exception(e)
398
+ error_detail = "Open WebUI: Server Connection Error"
399
+ if r is not None:
400
+ try:
401
+ res = r.json()
402
+ if "error" in res:
403
+ error_detail = f"Ollama: {res['error']}"
404
+ except:
405
+ error_detail = f"Ollama: {e}"
406
+
407
+ raise HTTPException(
408
+ status_code=r.status_code if r else 500,
409
+ detail=error_detail,
410
+ )
411
+
412
+
413
+ class CreateModelForm(BaseModel):
414
+ name: str
415
+ modelfile: Optional[str] = None
416
+ stream: Optional[bool] = None
417
+ path: Optional[str] = None
418
+
419
+
420
+ @app.post("/api/create")
421
+ @app.post("/api/create/{url_idx}")
422
+ async def create_model(
423
+ form_data: CreateModelForm, url_idx: int = 0, user=Depends(get_admin_user)
424
+ ):
425
+ log.debug(f"form_data: {form_data}")
426
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
427
+ log.info(f"url: {url}")
428
+
429
+ r = None
430
+
431
+ def get_request():
432
+ nonlocal url
433
+ nonlocal r
434
+ try:
435
+
436
+ def stream_content():
437
+ for chunk in r.iter_content(chunk_size=8192):
438
+ yield chunk
439
+
440
+ r = requests.request(
441
+ method="POST",
442
+ url=f"{url}/api/create",
443
+ data=form_data.model_dump_json(exclude_none=True).encode(),
444
+ stream=True,
445
+ )
446
+
447
+ r.raise_for_status()
448
+
449
+ log.debug(f"r: {r}")
450
+
451
+ return StreamingResponse(
452
+ stream_content(),
453
+ status_code=r.status_code,
454
+ headers=dict(r.headers),
455
+ )
456
+ except Exception as e:
457
+ raise e
458
+
459
+ try:
460
+ return await run_in_threadpool(get_request)
461
+ except Exception as e:
462
+ log.exception(e)
463
+ error_detail = "Open WebUI: Server Connection Error"
464
+ if r is not None:
465
+ try:
466
+ res = r.json()
467
+ if "error" in res:
468
+ error_detail = f"Ollama: {res['error']}"
469
+ except:
470
+ error_detail = f"Ollama: {e}"
471
+
472
+ raise HTTPException(
473
+ status_code=r.status_code if r else 500,
474
+ detail=error_detail,
475
+ )
476
+
477
+
478
+ class CopyModelForm(BaseModel):
479
+ source: str
480
+ destination: str
481
+
482
+
483
+ @app.post("/api/copy")
484
+ @app.post("/api/copy/{url_idx}")
485
+ async def copy_model(
486
+ form_data: CopyModelForm,
487
+ url_idx: Optional[int] = None,
488
+ user=Depends(get_admin_user),
489
+ ):
490
+ if url_idx == None:
491
+ if form_data.source in app.state.MODELS:
492
+ url_idx = app.state.MODELS[form_data.source]["urls"][0]
493
+ else:
494
+ raise HTTPException(
495
+ status_code=400,
496
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.source),
497
+ )
498
+
499
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
500
+ log.info(f"url: {url}")
501
+
502
+ try:
503
+ r = requests.request(
504
+ method="POST",
505
+ url=f"{url}/api/copy",
506
+ data=form_data.model_dump_json(exclude_none=True).encode(),
507
+ )
508
+ r.raise_for_status()
509
+
510
+ log.debug(f"r.text: {r.text}")
511
+
512
+ return True
513
+ except Exception as e:
514
+ log.exception(e)
515
+ error_detail = "Open WebUI: Server Connection Error"
516
+ if r is not None:
517
+ try:
518
+ res = r.json()
519
+ if "error" in res:
520
+ error_detail = f"Ollama: {res['error']}"
521
+ except:
522
+ error_detail = f"Ollama: {e}"
523
+
524
+ raise HTTPException(
525
+ status_code=r.status_code if r else 500,
526
+ detail=error_detail,
527
+ )
528
+
529
+
530
+ @app.delete("/api/delete")
531
+ @app.delete("/api/delete/{url_idx}")
532
+ async def delete_model(
533
+ form_data: ModelNameForm,
534
+ url_idx: Optional[int] = None,
535
+ user=Depends(get_admin_user),
536
+ ):
537
+ if url_idx == None:
538
+ if form_data.name in app.state.MODELS:
539
+ url_idx = app.state.MODELS[form_data.name]["urls"][0]
540
+ else:
541
+ raise HTTPException(
542
+ status_code=400,
543
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
544
+ )
545
+
546
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
547
+ log.info(f"url: {url}")
548
+
549
+ try:
550
+ r = requests.request(
551
+ method="DELETE",
552
+ url=f"{url}/api/delete",
553
+ data=form_data.model_dump_json(exclude_none=True).encode(),
554
+ )
555
+ r.raise_for_status()
556
+
557
+ log.debug(f"r.text: {r.text}")
558
+
559
+ return True
560
+ except Exception as e:
561
+ log.exception(e)
562
+ error_detail = "Open WebUI: Server Connection Error"
563
+ if r is not None:
564
+ try:
565
+ res = r.json()
566
+ if "error" in res:
567
+ error_detail = f"Ollama: {res['error']}"
568
+ except:
569
+ error_detail = f"Ollama: {e}"
570
+
571
+ raise HTTPException(
572
+ status_code=r.status_code if r else 500,
573
+ detail=error_detail,
574
+ )
575
+
576
+
577
+ @app.post("/api/show")
578
+ async def show_model_info(form_data: ModelNameForm, user=Depends(get_verified_user)):
579
+ if form_data.name not in app.state.MODELS:
580
+ raise HTTPException(
581
+ status_code=400,
582
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
583
+ )
584
+
585
+ url_idx = random.choice(app.state.MODELS[form_data.name]["urls"])
586
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
587
+ log.info(f"url: {url}")
588
+
589
+ try:
590
+ r = requests.request(
591
+ method="POST",
592
+ url=f"{url}/api/show",
593
+ data=form_data.model_dump_json(exclude_none=True).encode(),
594
+ )
595
+ r.raise_for_status()
596
+
597
+ return r.json()
598
+ except Exception as e:
599
+ log.exception(e)
600
+ error_detail = "Open WebUI: Server Connection Error"
601
+ if r is not None:
602
+ try:
603
+ res = r.json()
604
+ if "error" in res:
605
+ error_detail = f"Ollama: {res['error']}"
606
+ except:
607
+ error_detail = f"Ollama: {e}"
608
+
609
+ raise HTTPException(
610
+ status_code=r.status_code if r else 500,
611
+ detail=error_detail,
612
+ )
613
+
614
+
615
+ class GenerateEmbeddingsForm(BaseModel):
616
+ model: str
617
+ prompt: str
618
+ options: Optional[dict] = None
619
+ keep_alive: Optional[Union[int, str]] = None
620
+
621
+
622
+ @app.post("/api/embeddings")
623
+ @app.post("/api/embeddings/{url_idx}")
624
+ async def generate_embeddings(
625
+ form_data: GenerateEmbeddingsForm,
626
+ url_idx: Optional[int] = None,
627
+ user=Depends(get_verified_user),
628
+ ):
629
+ if url_idx == None:
630
+ model = form_data.model
631
+
632
+ if ":" not in model:
633
+ model = f"{model}:latest"
634
+
635
+ if model in app.state.MODELS:
636
+ url_idx = random.choice(app.state.MODELS[model]["urls"])
637
+ else:
638
+ raise HTTPException(
639
+ status_code=400,
640
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
641
+ )
642
+
643
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
644
+ log.info(f"url: {url}")
645
+
646
+ try:
647
+ r = requests.request(
648
+ method="POST",
649
+ url=f"{url}/api/embeddings",
650
+ data=form_data.model_dump_json(exclude_none=True).encode(),
651
+ )
652
+ r.raise_for_status()
653
+
654
+ return r.json()
655
+ except Exception as e:
656
+ log.exception(e)
657
+ error_detail = "Open WebUI: Server Connection Error"
658
+ if r is not None:
659
+ try:
660
+ res = r.json()
661
+ if "error" in res:
662
+ error_detail = f"Ollama: {res['error']}"
663
+ except:
664
+ error_detail = f"Ollama: {e}"
665
+
666
+ raise HTTPException(
667
+ status_code=r.status_code if r else 500,
668
+ detail=error_detail,
669
+ )
670
+
671
+
672
+ def generate_ollama_embeddings(
673
+ form_data: GenerateEmbeddingsForm,
674
+ url_idx: Optional[int] = None,
675
+ ):
676
+
677
+ log.info(f"generate_ollama_embeddings {form_data}")
678
+
679
+ if url_idx == None:
680
+ model = form_data.model
681
+
682
+ if ":" not in model:
683
+ model = f"{model}:latest"
684
+
685
+ if model in app.state.MODELS:
686
+ url_idx = random.choice(app.state.MODELS[model]["urls"])
687
+ else:
688
+ raise HTTPException(
689
+ status_code=400,
690
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
691
+ )
692
+
693
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
694
+ log.info(f"url: {url}")
695
+
696
+ try:
697
+ r = requests.request(
698
+ method="POST",
699
+ url=f"{url}/api/embeddings",
700
+ data=form_data.model_dump_json(exclude_none=True).encode(),
701
+ )
702
+ r.raise_for_status()
703
+
704
+ data = r.json()
705
+
706
+ log.info(f"generate_ollama_embeddings {data}")
707
+
708
+ if "embedding" in data:
709
+ return data["embedding"]
710
+ else:
711
+ raise "Something went wrong :/"
712
+ except Exception as e:
713
+ log.exception(e)
714
+ error_detail = "Open WebUI: Server Connection Error"
715
+ if r is not None:
716
+ try:
717
+ res = r.json()
718
+ if "error" in res:
719
+ error_detail = f"Ollama: {res['error']}"
720
+ except:
721
+ error_detail = f"Ollama: {e}"
722
+
723
+ raise error_detail
724
+
725
+
726
+ class GenerateCompletionForm(BaseModel):
727
+ model: str
728
+ prompt: str
729
+ images: Optional[List[str]] = None
730
+ format: Optional[str] = None
731
+ options: Optional[dict] = None
732
+ system: Optional[str] = None
733
+ template: Optional[str] = None
734
+ context: Optional[str] = None
735
+ stream: Optional[bool] = True
736
+ raw: Optional[bool] = None
737
+ keep_alive: Optional[Union[int, str]] = None
738
+
739
+
740
+ @app.post("/api/generate")
741
+ @app.post("/api/generate/{url_idx}")
742
+ async def generate_completion(
743
+ form_data: GenerateCompletionForm,
744
+ url_idx: Optional[int] = None,
745
+ user=Depends(get_verified_user),
746
+ ):
747
+
748
+ if url_idx == None:
749
+ model = form_data.model
750
+
751
+ if ":" not in model:
752
+ model = f"{model}:latest"
753
+
754
+ if model in app.state.MODELS:
755
+ url_idx = random.choice(app.state.MODELS[model]["urls"])
756
+ else:
757
+ raise HTTPException(
758
+ status_code=400,
759
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
760
+ )
761
+
762
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
763
+ log.info(f"url: {url}")
764
+
765
+ r = None
766
+
767
+ def get_request():
768
+ nonlocal form_data
769
+ nonlocal r
770
+
771
+ request_id = str(uuid.uuid4())
772
+ try:
773
+ REQUEST_POOL.append(request_id)
774
+
775
+ def stream_content():
776
+ try:
777
+ if form_data.stream:
778
+ yield json.dumps({"id": request_id, "done": False}) + "\n"
779
+
780
+ for chunk in r.iter_content(chunk_size=8192):
781
+ if request_id in REQUEST_POOL:
782
+ yield chunk
783
+ else:
784
+ log.warning("User: canceled request")
785
+ break
786
+ finally:
787
+ if hasattr(r, "close"):
788
+ r.close()
789
+ if request_id in REQUEST_POOL:
790
+ REQUEST_POOL.remove(request_id)
791
+
792
+ r = requests.request(
793
+ method="POST",
794
+ url=f"{url}/api/generate",
795
+ data=form_data.model_dump_json(exclude_none=True).encode(),
796
+ stream=True,
797
+ )
798
+
799
+ r.raise_for_status()
800
+
801
+ return StreamingResponse(
802
+ stream_content(),
803
+ status_code=r.status_code,
804
+ headers=dict(r.headers),
805
+ )
806
+ except Exception as e:
807
+ raise e
808
+
809
+ try:
810
+ return await run_in_threadpool(get_request)
811
+ except Exception as e:
812
+ error_detail = "Open WebUI: Server Connection Error"
813
+ if r is not None:
814
+ try:
815
+ res = r.json()
816
+ if "error" in res:
817
+ error_detail = f"Ollama: {res['error']}"
818
+ except:
819
+ error_detail = f"Ollama: {e}"
820
+
821
+ raise HTTPException(
822
+ status_code=r.status_code if r else 500,
823
+ detail=error_detail,
824
+ )
825
+
826
+
827
+ class ChatMessage(BaseModel):
828
+ role: str
829
+ content: str
830
+ images: Optional[List[str]] = None
831
+
832
+
833
+ class GenerateChatCompletionForm(BaseModel):
834
+ model: str
835
+ messages: List[ChatMessage]
836
+ format: Optional[str] = None
837
+ options: Optional[dict] = None
838
+ template: Optional[str] = None
839
+ stream: Optional[bool] = None
840
+ keep_alive: Optional[Union[int, str]] = None
841
+
842
+
843
+ @app.post("/api/chat")
844
+ @app.post("/api/chat/{url_idx}")
845
+ async def generate_chat_completion(
846
+ form_data: GenerateChatCompletionForm,
847
+ url_idx: Optional[int] = None,
848
+ user=Depends(get_verified_user),
849
+ ):
850
+
851
+ if url_idx == None:
852
+ model = form_data.model
853
+
854
+ if ":" not in model:
855
+ model = f"{model}:latest"
856
+
857
+ if model in app.state.MODELS:
858
+ url_idx = random.choice(app.state.MODELS[model]["urls"])
859
+ else:
860
+ raise HTTPException(
861
+ status_code=400,
862
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
863
+ )
864
+
865
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
866
+ log.info(f"url: {url}")
867
+
868
+ r = None
869
+
870
+ log.debug(
871
+ "form_data.model_dump_json(exclude_none=True).encode(): {0} ".format(
872
+ form_data.model_dump_json(exclude_none=True).encode()
873
+ )
874
+ )
875
+
876
+ def get_request():
877
+ nonlocal form_data
878
+ nonlocal r
879
+
880
+ request_id = str(uuid.uuid4())
881
+ try:
882
+ REQUEST_POOL.append(request_id)
883
+
884
+ def stream_content():
885
+ try:
886
+ if form_data.stream:
887
+ yield json.dumps({"id": request_id, "done": False}) + "\n"
888
+
889
+ for chunk in r.iter_content(chunk_size=8192):
890
+ if request_id in REQUEST_POOL:
891
+ yield chunk
892
+ else:
893
+ log.warning("User: canceled request")
894
+ break
895
+ finally:
896
+ if hasattr(r, "close"):
897
+ r.close()
898
+ if request_id in REQUEST_POOL:
899
+ REQUEST_POOL.remove(request_id)
900
+
901
+ r = requests.request(
902
+ method="POST",
903
+ url=f"{url}/api/chat",
904
+ data=form_data.model_dump_json(exclude_none=True).encode(),
905
+ stream=True,
906
+ )
907
+
908
+ r.raise_for_status()
909
+
910
+ return StreamingResponse(
911
+ stream_content(),
912
+ status_code=r.status_code,
913
+ headers=dict(r.headers),
914
+ )
915
+ except Exception as e:
916
+ log.exception(e)
917
+ raise e
918
+
919
+ try:
920
+ return await run_in_threadpool(get_request)
921
+ except Exception as e:
922
+ error_detail = "Open WebUI: Server Connection Error"
923
+ if r is not None:
924
+ try:
925
+ res = r.json()
926
+ if "error" in res:
927
+ error_detail = f"Ollama: {res['error']}"
928
+ except:
929
+ error_detail = f"Ollama: {e}"
930
+
931
+ raise HTTPException(
932
+ status_code=r.status_code if r else 500,
933
+ detail=error_detail,
934
+ )
935
+
936
+
937
+ # TODO: we should update this part once Ollama supports other types
938
+ class OpenAIChatMessage(BaseModel):
939
+ role: str
940
+ content: str
941
+
942
+ model_config = ConfigDict(extra="allow")
943
+
944
+
945
+ class OpenAIChatCompletionForm(BaseModel):
946
+ model: str
947
+ messages: List[OpenAIChatMessage]
948
+
949
+ model_config = ConfigDict(extra="allow")
950
+
951
+
952
+ @app.post("/v1/chat/completions")
953
+ @app.post("/v1/chat/completions/{url_idx}")
954
+ async def generate_openai_chat_completion(
955
+ form_data: OpenAIChatCompletionForm,
956
+ url_idx: Optional[int] = None,
957
+ user=Depends(get_verified_user),
958
+ ):
959
+
960
+ if url_idx == None:
961
+ model = form_data.model
962
+
963
+ if ":" not in model:
964
+ model = f"{model}:latest"
965
+
966
+ if model in app.state.MODELS:
967
+ url_idx = random.choice(app.state.MODELS[model]["urls"])
968
+ else:
969
+ raise HTTPException(
970
+ status_code=400,
971
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
972
+ )
973
+
974
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
975
+ log.info(f"url: {url}")
976
+
977
+ r = None
978
+
979
+ def get_request():
980
+ nonlocal form_data
981
+ nonlocal r
982
+
983
+ request_id = str(uuid.uuid4())
984
+ try:
985
+ REQUEST_POOL.append(request_id)
986
+
987
+ def stream_content():
988
+ try:
989
+ if form_data.stream:
990
+ yield json.dumps(
991
+ {"request_id": request_id, "done": False}
992
+ ) + "\n"
993
+
994
+ for chunk in r.iter_content(chunk_size=8192):
995
+ if request_id in REQUEST_POOL:
996
+ yield chunk
997
+ else:
998
+ log.warning("User: canceled request")
999
+ break
1000
+ finally:
1001
+ if hasattr(r, "close"):
1002
+ r.close()
1003
+ if request_id in REQUEST_POOL:
1004
+ REQUEST_POOL.remove(request_id)
1005
+
1006
+ r = requests.request(
1007
+ method="POST",
1008
+ url=f"{url}/v1/chat/completions",
1009
+ data=form_data.model_dump_json(exclude_none=True).encode(),
1010
+ stream=True,
1011
+ )
1012
+
1013
+ r.raise_for_status()
1014
+
1015
+ return StreamingResponse(
1016
+ stream_content(),
1017
+ status_code=r.status_code,
1018
+ headers=dict(r.headers),
1019
+ )
1020
+ except Exception as e:
1021
+ raise e
1022
+
1023
+ try:
1024
+ return await run_in_threadpool(get_request)
1025
+ except Exception as e:
1026
+ error_detail = "Open WebUI: Server Connection Error"
1027
+ if r is not None:
1028
+ try:
1029
+ res = r.json()
1030
+ if "error" in res:
1031
+ error_detail = f"Ollama: {res['error']}"
1032
+ except:
1033
+ error_detail = f"Ollama: {e}"
1034
+
1035
+ raise HTTPException(
1036
+ status_code=r.status_code if r else 500,
1037
+ detail=error_detail,
1038
+ )
1039
+
1040
+
1041
+ @app.get("/v1/models")
1042
+ @app.get("/v1/models/{url_idx}")
1043
+ async def get_openai_models(
1044
+ url_idx: Optional[int] = None,
1045
+ user=Depends(get_verified_user),
1046
+ ):
1047
+ if url_idx == None:
1048
+ models = await get_all_models()
1049
+
1050
+ if app.state.config.ENABLE_MODEL_FILTER:
1051
+ if user.role == "user":
1052
+ models["models"] = list(
1053
+ filter(
1054
+ lambda model: model["name"]
1055
+ in app.state.config.MODEL_FILTER_LIST,
1056
+ models["models"],
1057
+ )
1058
+ )
1059
+
1060
+ return {
1061
+ "data": [
1062
+ {
1063
+ "id": model["model"],
1064
+ "object": "model",
1065
+ "created": int(time.time()),
1066
+ "owned_by": "openai",
1067
+ }
1068
+ for model in models["models"]
1069
+ ],
1070
+ "object": "list",
1071
+ }
1072
+
1073
+ else:
1074
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
1075
+ try:
1076
+ r = requests.request(method="GET", url=f"{url}/api/tags")
1077
+ r.raise_for_status()
1078
+
1079
+ models = r.json()
1080
+
1081
+ return {
1082
+ "data": [
1083
+ {
1084
+ "id": model["model"],
1085
+ "object": "model",
1086
+ "created": int(time.time()),
1087
+ "owned_by": "openai",
1088
+ }
1089
+ for model in models["models"]
1090
+ ],
1091
+ "object": "list",
1092
+ }
1093
+
1094
+ except Exception as e:
1095
+ log.exception(e)
1096
+ error_detail = "Open WebUI: Server Connection Error"
1097
+ if r is not None:
1098
+ try:
1099
+ res = r.json()
1100
+ if "error" in res:
1101
+ error_detail = f"Ollama: {res['error']}"
1102
+ except:
1103
+ error_detail = f"Ollama: {e}"
1104
+
1105
+ raise HTTPException(
1106
+ status_code=r.status_code if r else 500,
1107
+ detail=error_detail,
1108
+ )
1109
+
1110
+
1111
+ class UrlForm(BaseModel):
1112
+ url: str
1113
+
1114
+
1115
+ class UploadBlobForm(BaseModel):
1116
+ filename: str
1117
+
1118
+
1119
+ def parse_huggingface_url(hf_url):
1120
+ try:
1121
+ # Parse the URL
1122
+ parsed_url = urlparse(hf_url)
1123
+
1124
+ # Get the path and split it into components
1125
+ path_components = parsed_url.path.split("/")
1126
+
1127
+ # Extract the desired output
1128
+ user_repo = "/".join(path_components[1:3])
1129
+ model_file = path_components[-1]
1130
+
1131
+ return model_file
1132
+ except ValueError:
1133
+ return None
1134
+
1135
+
1136
+ async def download_file_stream(
1137
+ ollama_url, file_url, file_path, file_name, chunk_size=1024 * 1024
1138
+ ):
1139
+ done = False
1140
+
1141
+ if os.path.exists(file_path):
1142
+ current_size = os.path.getsize(file_path)
1143
+ else:
1144
+ current_size = 0
1145
+
1146
+ headers = {"Range": f"bytes={current_size}-"} if current_size > 0 else {}
1147
+
1148
+ timeout = aiohttp.ClientTimeout(total=600) # Set the timeout
1149
+
1150
+ async with aiohttp.ClientSession(timeout=timeout) as session:
1151
+ async with session.get(file_url, headers=headers) as response:
1152
+ total_size = int(response.headers.get("content-length", 0)) + current_size
1153
+
1154
+ with open(file_path, "ab+") as file:
1155
+ async for data in response.content.iter_chunked(chunk_size):
1156
+ current_size += len(data)
1157
+ file.write(data)
1158
+
1159
+ done = current_size == total_size
1160
+ progress = round((current_size / total_size) * 100, 2)
1161
+
1162
+ yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
1163
+
1164
+ if done:
1165
+ file.seek(0)
1166
+ hashed = calculate_sha256(file)
1167
+ file.seek(0)
1168
+
1169
+ url = f"{ollama_url}/api/blobs/sha256:{hashed}"
1170
+ response = requests.post(url, data=file)
1171
+
1172
+ if response.ok:
1173
+ res = {
1174
+ "done": done,
1175
+ "blob": f"sha256:{hashed}",
1176
+ "name": file_name,
1177
+ }
1178
+ os.remove(file_path)
1179
+
1180
+ yield f"data: {json.dumps(res)}\n\n"
1181
+ else:
1182
+ raise "Ollama: Could not create blob, Please try again."
1183
+
1184
+
1185
+ # def number_generator():
1186
+ # for i in range(1, 101):
1187
+ # yield f"data: {i}\n"
1188
+
1189
+
1190
+ # url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
1191
+ @app.post("/models/download")
1192
+ @app.post("/models/download/{url_idx}")
1193
+ async def download_model(
1194
+ form_data: UrlForm,
1195
+ url_idx: Optional[int] = None,
1196
+ ):
1197
+
1198
+ allowed_hosts = ["https://huggingface.co/", "https://github.com/"]
1199
+
1200
+ if not any(form_data.url.startswith(host) for host in allowed_hosts):
1201
+ raise HTTPException(
1202
+ status_code=400,
1203
+ detail="Invalid file_url. Only URLs from allowed hosts are permitted.",
1204
+ )
1205
+
1206
+ if url_idx == None:
1207
+ url_idx = 0
1208
+ url = app.state.config.OLLAMA_BASE_URLS[url_idx]
1209
+
1210
+ file_name = parse_huggingface_url(form_data.url)
1211
+
1212
+ if file_name:
1213
+ file_path = f"{UPLOAD_DIR}/{file_name}"
1214
+
1215
+ return StreamingResponse(
1216
+ download_file_stream(url, form_data.url, file_path, file_name),
1217
+ )
1218
+ else:
1219
+ return None
1220
+
1221
+
1222
+ @app.post("/models/upload")
1223
+ @app.post("/models/upload/{url_idx}")
1224
+ def upload_model(file: UploadFile = File(...), url_idx: Optional[int] = None):
1225
+ if url_idx == None:
1226
+ url_idx = 0
1227
+ ollama_url = app.state.config.OLLAMA_BASE_URLS[url_idx]
1228
+
1229
+ file_path = f"{UPLOAD_DIR}/{file.filename}"
1230
+
1231
+ # Save file in chunks
1232
+ with open(file_path, "wb+") as f:
1233
+ for chunk in file.file:
1234
+ f.write(chunk)
1235
+
1236
+ def file_process_stream():
1237
+ nonlocal ollama_url
1238
+ total_size = os.path.getsize(file_path)
1239
+ chunk_size = 1024 * 1024
1240
+ try:
1241
+ with open(file_path, "rb") as f:
1242
+ total = 0
1243
+ done = False
1244
+
1245
+ while not done:
1246
+ chunk = f.read(chunk_size)
1247
+ if not chunk:
1248
+ done = True
1249
+ continue
1250
+
1251
+ total += len(chunk)
1252
+ progress = round((total / total_size) * 100, 2)
1253
+
1254
+ res = {
1255
+ "progress": progress,
1256
+ "total": total_size,
1257
+ "completed": total,
1258
+ }
1259
+ yield f"data: {json.dumps(res)}\n\n"
1260
+
1261
+ if done:
1262
+ f.seek(0)
1263
+ hashed = calculate_sha256(f)
1264
+ f.seek(0)
1265
+
1266
+ url = f"{ollama_url}/api/blobs/sha256:{hashed}"
1267
+ response = requests.post(url, data=f)
1268
+
1269
+ if response.ok:
1270
+ res = {
1271
+ "done": done,
1272
+ "blob": f"sha256:{hashed}",
1273
+ "name": file.filename,
1274
+ }
1275
+ os.remove(file_path)
1276
+ yield f"data: {json.dumps(res)}\n\n"
1277
+ else:
1278
+ raise Exception(
1279
+ "Ollama: Could not create blob, Please try again."
1280
+ )
1281
+
1282
+ except Exception as e:
1283
+ res = {"error": str(e)}
1284
+ yield f"data: {json.dumps(res)}\n\n"
1285
+
1286
+ return StreamingResponse(file_process_stream(), media_type="text/event-stream")
1287
+
1288
+
1289
+ # async def upload_model(file: UploadFile = File(), url_idx: Optional[int] = None):
1290
+ # if url_idx == None:
1291
+ # url_idx = 0
1292
+ # url = app.state.config.OLLAMA_BASE_URLS[url_idx]
1293
+
1294
+ # file_location = os.path.join(UPLOAD_DIR, file.filename)
1295
+ # total_size = file.size
1296
+
1297
+ # async def file_upload_generator(file):
1298
+ # print(file)
1299
+ # try:
1300
+ # async with aiofiles.open(file_location, "wb") as f:
1301
+ # completed_size = 0
1302
+ # while True:
1303
+ # chunk = await file.read(1024*1024)
1304
+ # if not chunk:
1305
+ # break
1306
+ # await f.write(chunk)
1307
+ # completed_size += len(chunk)
1308
+ # progress = (completed_size / total_size) * 100
1309
+
1310
+ # print(progress)
1311
+ # yield f'data: {json.dumps({"status": "uploading", "percentage": progress, "total": total_size, "completed": completed_size, "done": False})}\n'
1312
+ # except Exception as e:
1313
+ # print(e)
1314
+ # yield f"data: {json.dumps({'status': 'error', 'message': str(e)})}\n"
1315
+ # finally:
1316
+ # await file.close()
1317
+ # print("done")
1318
+ # yield f'data: {json.dumps({"status": "completed", "percentage": 100, "total": total_size, "completed": completed_size, "done": True})}\n'
1319
+
1320
+ # return StreamingResponse(
1321
+ # file_upload_generator(copy.deepcopy(file)), media_type="text/event-stream"
1322
+ # )
1323
+
1324
+
1325
+ @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
1326
+ async def deprecated_proxy(
1327
+ path: str, request: Request, user=Depends(get_verified_user)
1328
+ ):
1329
+ url = app.state.config.OLLAMA_BASE_URLS[0]
1330
+ target_url = f"{url}/{path}"
1331
+
1332
+ body = await request.body()
1333
+ headers = dict(request.headers)
1334
+
1335
+ if user.role in ["user", "admin"]:
1336
+ if path in ["pull", "delete", "push", "copy", "create"]:
1337
+ if user.role != "admin":
1338
+ raise HTTPException(
1339
+ status_code=status.HTTP_401_UNAUTHORIZED,
1340
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1341
+ )
1342
+ else:
1343
+ raise HTTPException(
1344
+ status_code=status.HTTP_401_UNAUTHORIZED,
1345
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1346
+ )
1347
+
1348
+ headers.pop("host", None)
1349
+ headers.pop("authorization", None)
1350
+ headers.pop("origin", None)
1351
+ headers.pop("referer", None)
1352
+
1353
+ r = None
1354
+
1355
+ def get_request():
1356
+ nonlocal r
1357
+
1358
+ request_id = str(uuid.uuid4())
1359
+ try:
1360
+ REQUEST_POOL.append(request_id)
1361
+
1362
+ def stream_content():
1363
+ try:
1364
+ if path == "generate":
1365
+ data = json.loads(body.decode("utf-8"))
1366
+
1367
+ if not ("stream" in data and data["stream"] == False):
1368
+ yield json.dumps({"id": request_id, "done": False}) + "\n"
1369
+
1370
+ elif path == "chat":
1371
+ yield json.dumps({"id": request_id, "done": False}) + "\n"
1372
+
1373
+ for chunk in r.iter_content(chunk_size=8192):
1374
+ if request_id in REQUEST_POOL:
1375
+ yield chunk
1376
+ else:
1377
+ log.warning("User: canceled request")
1378
+ break
1379
+ finally:
1380
+ if hasattr(r, "close"):
1381
+ r.close()
1382
+ if request_id in REQUEST_POOL:
1383
+ REQUEST_POOL.remove(request_id)
1384
+
1385
+ r = requests.request(
1386
+ method=request.method,
1387
+ url=target_url,
1388
+ data=body,
1389
+ headers=headers,
1390
+ stream=True,
1391
+ )
1392
+
1393
+ r.raise_for_status()
1394
+
1395
+ # r.close()
1396
+
1397
+ return StreamingResponse(
1398
+ stream_content(),
1399
+ status_code=r.status_code,
1400
+ headers=dict(r.headers),
1401
+ )
1402
+ except Exception as e:
1403
+ raise e
1404
+
1405
+ try:
1406
+ return await run_in_threadpool(get_request)
1407
+ except Exception as e:
1408
+ error_detail = "Open WebUI: Server Connection Error"
1409
+ if r is not None:
1410
+ try:
1411
+ res = r.json()
1412
+ if "error" in res:
1413
+ error_detail = f"Ollama: {res['error']}"
1414
+ except:
1415
+ error_detail = f"Ollama: {e}"
1416
+
1417
+ raise HTTPException(
1418
+ status_code=r.status_code if r else 500,
1419
+ detail=error_detail,
1420
+ )
backend/apps/openai/main.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request, Response, HTTPException, Depends
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
4
+
5
+ import requests
6
+ import aiohttp
7
+ import asyncio
8
+ import json
9
+ import logging
10
+
11
+ from pydantic import BaseModel
12
+
13
+
14
+ from apps.web.models.users import Users
15
+ from constants import ERROR_MESSAGES
16
+ from utils.utils import (
17
+ decode_token,
18
+ get_current_user,
19
+ get_verified_user,
20
+ get_admin_user,
21
+ )
22
+ from config import (
23
+ SRC_LOG_LEVELS,
24
+ ENABLE_OPENAI_API,
25
+ OPENAI_API_BASE_URLS,
26
+ OPENAI_API_KEYS,
27
+ CACHE_DIR,
28
+ ENABLE_MODEL_FILTER,
29
+ MODEL_FILTER_LIST,
30
+ AppConfig,
31
+ )
32
+ from typing import List, Optional
33
+
34
+
35
+ import hashlib
36
+ from pathlib import Path
37
+
38
+ log = logging.getLogger(__name__)
39
+ log.setLevel(SRC_LOG_LEVELS["OPENAI"])
40
+
41
+ app = FastAPI()
42
+ app.add_middleware(
43
+ CORSMiddleware,
44
+ allow_origins=["*"],
45
+ allow_credentials=True,
46
+ allow_methods=["*"],
47
+ allow_headers=["*"],
48
+ )
49
+
50
+
51
+ app.state.config = AppConfig()
52
+
53
+ app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER
54
+ app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST
55
+
56
+
57
+ app.state.config.ENABLE_OPENAI_API = ENABLE_OPENAI_API
58
+ app.state.config.OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS
59
+ app.state.config.OPENAI_API_KEYS = OPENAI_API_KEYS
60
+
61
+ app.state.MODELS = {}
62
+
63
+
64
+ @app.middleware("http")
65
+ async def check_url(request: Request, call_next):
66
+ if len(app.state.MODELS) == 0:
67
+ await get_all_models()
68
+ else:
69
+ pass
70
+
71
+ response = await call_next(request)
72
+ return response
73
+
74
+
75
+ @app.get("/config")
76
+ async def get_config(user=Depends(get_admin_user)):
77
+ return {"ENABLE_OPENAI_API": app.state.config.ENABLE_OPENAI_API}
78
+
79
+
80
+ class OpenAIConfigForm(BaseModel):
81
+ enable_openai_api: Optional[bool] = None
82
+
83
+
84
+ @app.post("/config/update")
85
+ async def update_config(form_data: OpenAIConfigForm, user=Depends(get_admin_user)):
86
+ app.state.config.ENABLE_OPENAI_API = form_data.enable_openai_api
87
+ return {"ENABLE_OPENAI_API": app.state.config.ENABLE_OPENAI_API}
88
+
89
+
90
+ class UrlsUpdateForm(BaseModel):
91
+ urls: List[str]
92
+
93
+
94
+ class KeysUpdateForm(BaseModel):
95
+ keys: List[str]
96
+
97
+
98
+ @app.get("/urls")
99
+ async def get_openai_urls(user=Depends(get_admin_user)):
100
+ return {"OPENAI_API_BASE_URLS": app.state.config.OPENAI_API_BASE_URLS}
101
+
102
+
103
+ @app.post("/urls/update")
104
+ async def update_openai_urls(form_data: UrlsUpdateForm, user=Depends(get_admin_user)):
105
+ await get_all_models()
106
+ app.state.config.OPENAI_API_BASE_URLS = form_data.urls
107
+ return {"OPENAI_API_BASE_URLS": app.state.config.OPENAI_API_BASE_URLS}
108
+
109
+
110
+ @app.get("/keys")
111
+ async def get_openai_keys(user=Depends(get_admin_user)):
112
+ return {"OPENAI_API_KEYS": app.state.config.OPENAI_API_KEYS}
113
+
114
+
115
+ @app.post("/keys/update")
116
+ async def update_openai_key(form_data: KeysUpdateForm, user=Depends(get_admin_user)):
117
+ app.state.config.OPENAI_API_KEYS = form_data.keys
118
+ return {"OPENAI_API_KEYS": app.state.config.OPENAI_API_KEYS}
119
+
120
+
121
+ @app.post("/audio/speech")
122
+ async def speech(request: Request, user=Depends(get_verified_user)):
123
+ idx = None
124
+ try:
125
+ idx = app.state.config.OPENAI_API_BASE_URLS.index("https://api.openai.com/v1")
126
+ body = await request.body()
127
+ name = hashlib.sha256(body).hexdigest()
128
+
129
+ SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
130
+ SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
131
+ file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
132
+ file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
133
+
134
+ # Check if the file already exists in the cache
135
+ if file_path.is_file():
136
+ return FileResponse(file_path)
137
+
138
+ headers = {}
139
+ headers["Authorization"] = f"Bearer {app.state.config.OPENAI_API_KEYS[idx]}"
140
+ headers["Content-Type"] = "application/json"
141
+ if "openrouter.ai" in app.state.config.OPENAI_API_BASE_URLS[idx]:
142
+ headers["HTTP-Referer"] = "https://openwebui.com/"
143
+ headers["X-Title"] = "Open WebUI"
144
+ r = None
145
+ try:
146
+ r = requests.post(
147
+ url=f"{app.state.config.OPENAI_API_BASE_URLS[idx]}/audio/speech",
148
+ data=body,
149
+ headers=headers,
150
+ stream=True,
151
+ )
152
+
153
+ r.raise_for_status()
154
+
155
+ # Save the streaming content to a file
156
+ with open(file_path, "wb") as f:
157
+ for chunk in r.iter_content(chunk_size=8192):
158
+ f.write(chunk)
159
+
160
+ with open(file_body_path, "w") as f:
161
+ json.dump(json.loads(body.decode("utf-8")), f)
162
+
163
+ # Return the saved file
164
+ return FileResponse(file_path)
165
+
166
+ except Exception as e:
167
+ log.exception(e)
168
+ error_detail = "Open WebUI: Server Connection Error"
169
+ if r is not None:
170
+ try:
171
+ res = r.json()
172
+ if "error" in res:
173
+ error_detail = f"External: {res['error']}"
174
+ except:
175
+ error_detail = f"External: {e}"
176
+
177
+ raise HTTPException(
178
+ status_code=r.status_code if r else 500, detail=error_detail
179
+ )
180
+
181
+ except ValueError:
182
+ raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND)
183
+
184
+
185
+ async def fetch_url(url, key):
186
+ timeout = aiohttp.ClientTimeout(total=5)
187
+ try:
188
+ if key != "":
189
+ headers = {"Authorization": f"Bearer {key}"}
190
+ async with aiohttp.ClientSession(timeout=timeout) as session:
191
+ async with session.get(url, headers=headers) as response:
192
+ return await response.json()
193
+ else:
194
+ return None
195
+ except Exception as e:
196
+ # Handle connection error here
197
+ log.error(f"Connection error: {e}")
198
+ return None
199
+
200
+
201
+ def merge_models_lists(model_lists):
202
+ log.info(f"merge_models_lists {model_lists}")
203
+ merged_list = []
204
+
205
+ for idx, models in enumerate(model_lists):
206
+ if models is not None and "error" not in models:
207
+ merged_list.extend(
208
+ [
209
+ {**model, "urlIdx": idx}
210
+ for model in models
211
+ if "api.openai.com"
212
+ not in app.state.config.OPENAI_API_BASE_URLS[idx]
213
+ or "gpt" in model["id"]
214
+ ]
215
+ )
216
+
217
+ return merged_list
218
+
219
+
220
+ async def get_all_models():
221
+ log.info("get_all_models()")
222
+
223
+ if (
224
+ len(app.state.config.OPENAI_API_KEYS) == 1
225
+ and app.state.config.OPENAI_API_KEYS[0] == ""
226
+ ) or not app.state.config.ENABLE_OPENAI_API:
227
+ models = {"data": []}
228
+ else:
229
+ tasks = [
230
+ fetch_url(f"{url}/models", app.state.config.OPENAI_API_KEYS[idx])
231
+ for idx, url in enumerate(app.state.config.OPENAI_API_BASE_URLS)
232
+ ]
233
+
234
+ responses = await asyncio.gather(*tasks)
235
+ log.info(f"get_all_models:responses() {responses}")
236
+
237
+ models = {
238
+ "data": merge_models_lists(
239
+ list(
240
+ map(
241
+ lambda response: (
242
+ response["data"]
243
+ if (response and "data" in response)
244
+ else (response if isinstance(response, list) else None)
245
+ ),
246
+ responses,
247
+ )
248
+ )
249
+ )
250
+ }
251
+
252
+ log.info(f"models: {models}")
253
+ app.state.MODELS = {model["id"]: model for model in models["data"]}
254
+
255
+ return models
256
+
257
+
258
+ @app.get("/models")
259
+ @app.get("/models/{url_idx}")
260
+ async def get_models(url_idx: Optional[int] = None, user=Depends(get_current_user)):
261
+ if url_idx == None:
262
+ models = await get_all_models()
263
+ if app.state.config.ENABLE_MODEL_FILTER:
264
+ if user.role == "user":
265
+ models["data"] = list(
266
+ filter(
267
+ lambda model: model["id"] in app.state.config.MODEL_FILTER_LIST,
268
+ models["data"],
269
+ )
270
+ )
271
+ return models
272
+ return models
273
+ else:
274
+ url = app.state.config.OPENAI_API_BASE_URLS[url_idx]
275
+
276
+ r = None
277
+
278
+ try:
279
+ r = requests.request(method="GET", url=f"{url}/models")
280
+ r.raise_for_status()
281
+
282
+ response_data = r.json()
283
+ if "api.openai.com" in url:
284
+ response_data["data"] = list(
285
+ filter(lambda model: "gpt" in model["id"], response_data["data"])
286
+ )
287
+
288
+ return response_data
289
+ except Exception as e:
290
+ log.exception(e)
291
+ error_detail = "Open WebUI: Server Connection Error"
292
+ if r is not None:
293
+ try:
294
+ res = r.json()
295
+ if "error" in res:
296
+ error_detail = f"External: {res['error']}"
297
+ except:
298
+ error_detail = f"External: {e}"
299
+
300
+ raise HTTPException(
301
+ status_code=r.status_code if r else 500,
302
+ detail=error_detail,
303
+ )
304
+
305
+
306
+ @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
307
+ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
308
+ idx = 0
309
+
310
+ body = await request.body()
311
+ # TODO: Remove below after gpt-4-vision fix from Open AI
312
+ # Try to decode the body of the request from bytes to a UTF-8 string (Require add max_token to fix gpt-4-vision)
313
+ try:
314
+ body = body.decode("utf-8")
315
+ body = json.loads(body)
316
+
317
+ idx = app.state.MODELS[body.get("model")]["urlIdx"]
318
+
319
+ # Check if the model is "gpt-4-vision-preview" and set "max_tokens" to 4000
320
+ # This is a workaround until OpenAI fixes the issue with this model
321
+ if body.get("model") == "gpt-4-vision-preview":
322
+ if "max_tokens" not in body:
323
+ body["max_tokens"] = 4000
324
+ log.debug("Modified body_dict:", body)
325
+
326
+ # Fix for ChatGPT calls failing because the num_ctx key is in body
327
+ if "num_ctx" in body:
328
+ # If 'num_ctx' is in the dictionary, delete it
329
+ # Leaving it there generates an error with the
330
+ # OpenAI API (Feb 2024)
331
+ del body["num_ctx"]
332
+
333
+ # Convert the modified body back to JSON
334
+ body = json.dumps(body)
335
+ except json.JSONDecodeError as e:
336
+ log.error("Error loading request body into a dictionary:", e)
337
+
338
+ url = app.state.config.OPENAI_API_BASE_URLS[idx]
339
+ key = app.state.config.OPENAI_API_KEYS[idx]
340
+
341
+ target_url = f"{url}/{path}"
342
+
343
+ if key == "":
344
+ raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
345
+
346
+ headers = {}
347
+ headers["Authorization"] = f"Bearer {key}"
348
+ headers["Content-Type"] = "application/json"
349
+
350
+ r = None
351
+
352
+ try:
353
+ r = requests.request(
354
+ method=request.method,
355
+ url=target_url,
356
+ data=body,
357
+ headers=headers,
358
+ stream=True,
359
+ )
360
+
361
+ r.raise_for_status()
362
+
363
+ # Check if response is SSE
364
+ if "text/event-stream" in r.headers.get("Content-Type", ""):
365
+ return StreamingResponse(
366
+ r.iter_content(chunk_size=8192),
367
+ status_code=r.status_code,
368
+ headers=dict(r.headers),
369
+ )
370
+ else:
371
+ response_data = r.json()
372
+ return response_data
373
+ except Exception as e:
374
+ log.exception(e)
375
+ error_detail = "Open WebUI: Server Connection Error"
376
+ if r is not None:
377
+ try:
378
+ res = r.json()
379
+ if "error" in res:
380
+ error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
381
+ except:
382
+ error_detail = f"External: {e}"
383
+
384
+ raise HTTPException(
385
+ status_code=r.status_code if r else 500, detail=error_detail
386
+ )
backend/apps/rag/main.py ADDED
@@ -0,0 +1,971 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import (
2
+ FastAPI,
3
+ Depends,
4
+ HTTPException,
5
+ status,
6
+ UploadFile,
7
+ File,
8
+ Form,
9
+ )
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ import os, shutil, logging, re
12
+
13
+ from pathlib import Path
14
+ from typing import List
15
+
16
+ from chromadb.utils.batch_utils import create_batches
17
+
18
+ from langchain_community.document_loaders import (
19
+ WebBaseLoader,
20
+ TextLoader,
21
+ PyPDFLoader,
22
+ CSVLoader,
23
+ BSHTMLLoader,
24
+ Docx2txtLoader,
25
+ UnstructuredEPubLoader,
26
+ UnstructuredWordDocumentLoader,
27
+ UnstructuredMarkdownLoader,
28
+ UnstructuredXMLLoader,
29
+ UnstructuredRSTLoader,
30
+ UnstructuredExcelLoader,
31
+ UnstructuredPowerPointLoader,
32
+ YoutubeLoader,
33
+ )
34
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
35
+
36
+ import validators
37
+ import urllib.parse
38
+ import socket
39
+
40
+
41
+ from pydantic import BaseModel
42
+ from typing import Optional
43
+ import mimetypes
44
+ import uuid
45
+ import json
46
+
47
+ import sentence_transformers
48
+
49
+ from apps.web.models.documents import (
50
+ Documents,
51
+ DocumentForm,
52
+ DocumentResponse,
53
+ )
54
+
55
+ from apps.rag.utils import (
56
+ get_model_path,
57
+ get_embedding_function,
58
+ query_doc,
59
+ query_doc_with_hybrid_search,
60
+ query_collection,
61
+ query_collection_with_hybrid_search,
62
+ )
63
+
64
+ from utils.misc import (
65
+ calculate_sha256,
66
+ calculate_sha256_string,
67
+ sanitize_filename,
68
+ extract_folders_after_data_docs,
69
+ )
70
+ from utils.utils import get_current_user, get_admin_user
71
+
72
+ from config import (
73
+ ENV,
74
+ SRC_LOG_LEVELS,
75
+ UPLOAD_DIR,
76
+ DOCS_DIR,
77
+ RAG_TOP_K,
78
+ RAG_RELEVANCE_THRESHOLD,
79
+ RAG_EMBEDDING_ENGINE,
80
+ RAG_EMBEDDING_MODEL,
81
+ RAG_EMBEDDING_MODEL_AUTO_UPDATE,
82
+ RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
83
+ ENABLE_RAG_HYBRID_SEARCH,
84
+ ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
85
+ RAG_RERANKING_MODEL,
86
+ PDF_EXTRACT_IMAGES,
87
+ RAG_RERANKING_MODEL_AUTO_UPDATE,
88
+ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
89
+ RAG_OPENAI_API_BASE_URL,
90
+ RAG_OPENAI_API_KEY,
91
+ DEVICE_TYPE,
92
+ CHROMA_CLIENT,
93
+ CHUNK_SIZE,
94
+ CHUNK_OVERLAP,
95
+ RAG_TEMPLATE,
96
+ ENABLE_RAG_LOCAL_WEB_FETCH,
97
+ YOUTUBE_LOADER_LANGUAGE,
98
+ AppConfig,
99
+ )
100
+
101
+ from constants import ERROR_MESSAGES
102
+
103
+ log = logging.getLogger(__name__)
104
+ log.setLevel(SRC_LOG_LEVELS["RAG"])
105
+
106
+ app = FastAPI()
107
+
108
+ app.state.config = AppConfig()
109
+
110
+ app.state.config.TOP_K = RAG_TOP_K
111
+ app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD
112
+
113
+ app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH
114
+ app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
115
+ ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION
116
+ )
117
+
118
+ app.state.config.CHUNK_SIZE = CHUNK_SIZE
119
+ app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP
120
+
121
+ app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
122
+ app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
123
+ app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
124
+ app.state.config.RAG_TEMPLATE = RAG_TEMPLATE
125
+
126
+
127
+ app.state.config.OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL
128
+ app.state.config.OPENAI_API_KEY = RAG_OPENAI_API_KEY
129
+
130
+ app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES
131
+
132
+
133
+ app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE
134
+ app.state.YOUTUBE_LOADER_TRANSLATION = None
135
+
136
+
137
+ def update_embedding_model(
138
+ embedding_model: str,
139
+ update_model: bool = False,
140
+ ):
141
+ if embedding_model and app.state.config.RAG_EMBEDDING_ENGINE == "":
142
+ app.state.sentence_transformer_ef = sentence_transformers.SentenceTransformer(
143
+ get_model_path(embedding_model, update_model),
144
+ device=DEVICE_TYPE,
145
+ trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
146
+ )
147
+ else:
148
+ app.state.sentence_transformer_ef = None
149
+
150
+
151
+ def update_reranking_model(
152
+ reranking_model: str,
153
+ update_model: bool = False,
154
+ ):
155
+ if reranking_model:
156
+ app.state.sentence_transformer_rf = sentence_transformers.CrossEncoder(
157
+ get_model_path(reranking_model, update_model),
158
+ device=DEVICE_TYPE,
159
+ trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
160
+ )
161
+ else:
162
+ app.state.sentence_transformer_rf = None
163
+
164
+
165
+ update_embedding_model(
166
+ app.state.config.RAG_EMBEDDING_MODEL,
167
+ RAG_EMBEDDING_MODEL_AUTO_UPDATE,
168
+ )
169
+
170
+ update_reranking_model(
171
+ app.state.config.RAG_RERANKING_MODEL,
172
+ RAG_RERANKING_MODEL_AUTO_UPDATE,
173
+ )
174
+
175
+
176
+ app.state.EMBEDDING_FUNCTION = get_embedding_function(
177
+ app.state.config.RAG_EMBEDDING_ENGINE,
178
+ app.state.config.RAG_EMBEDDING_MODEL,
179
+ app.state.sentence_transformer_ef,
180
+ app.state.config.OPENAI_API_KEY,
181
+ app.state.config.OPENAI_API_BASE_URL,
182
+ )
183
+
184
+ origins = ["*"]
185
+
186
+
187
+ app.add_middleware(
188
+ CORSMiddleware,
189
+ allow_origins=origins,
190
+ allow_credentials=True,
191
+ allow_methods=["*"],
192
+ allow_headers=["*"],
193
+ )
194
+
195
+
196
+ class CollectionNameForm(BaseModel):
197
+ collection_name: Optional[str] = "test"
198
+
199
+
200
+ class UrlForm(CollectionNameForm):
201
+ url: str
202
+
203
+
204
+ @app.get("/")
205
+ async def get_status():
206
+ return {
207
+ "status": True,
208
+ "chunk_size": app.state.config.CHUNK_SIZE,
209
+ "chunk_overlap": app.state.config.CHUNK_OVERLAP,
210
+ "template": app.state.config.RAG_TEMPLATE,
211
+ "embedding_engine": app.state.config.RAG_EMBEDDING_ENGINE,
212
+ "embedding_model": app.state.config.RAG_EMBEDDING_MODEL,
213
+ "reranking_model": app.state.config.RAG_RERANKING_MODEL,
214
+ }
215
+
216
+
217
+ @app.get("/embedding")
218
+ async def get_embedding_config(user=Depends(get_admin_user)):
219
+ return {
220
+ "status": True,
221
+ "embedding_engine": app.state.config.RAG_EMBEDDING_ENGINE,
222
+ "embedding_model": app.state.config.RAG_EMBEDDING_MODEL,
223
+ "openai_config": {
224
+ "url": app.state.config.OPENAI_API_BASE_URL,
225
+ "key": app.state.config.OPENAI_API_KEY,
226
+ },
227
+ }
228
+
229
+
230
+ @app.get("/reranking")
231
+ async def get_reraanking_config(user=Depends(get_admin_user)):
232
+ return {
233
+ "status": True,
234
+ "reranking_model": app.state.config.RAG_RERANKING_MODEL,
235
+ }
236
+
237
+
238
+ class OpenAIConfigForm(BaseModel):
239
+ url: str
240
+ key: str
241
+
242
+
243
+ class EmbeddingModelUpdateForm(BaseModel):
244
+ openai_config: Optional[OpenAIConfigForm] = None
245
+ embedding_engine: str
246
+ embedding_model: str
247
+
248
+
249
+ @app.post("/embedding/update")
250
+ async def update_embedding_config(
251
+ form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)
252
+ ):
253
+ log.info(
254
+ f"Updating embedding model: {app.state.config.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}"
255
+ )
256
+ try:
257
+ app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine
258
+ app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model
259
+
260
+ if app.state.config.RAG_EMBEDDING_ENGINE in ["ollama", "openai"]:
261
+ if form_data.openai_config != None:
262
+ app.state.config.OPENAI_API_BASE_URL = form_data.openai_config.url
263
+ app.state.config.OPENAI_API_KEY = form_data.openai_config.key
264
+
265
+ update_embedding_model(app.state.config.RAG_EMBEDDING_MODEL)
266
+
267
+ app.state.EMBEDDING_FUNCTION = get_embedding_function(
268
+ app.state.config.RAG_EMBEDDING_ENGINE,
269
+ app.state.config.RAG_EMBEDDING_MODEL,
270
+ app.state.sentence_transformer_ef,
271
+ app.state.config.OPENAI_API_KEY,
272
+ app.state.config.OPENAI_API_BASE_URL,
273
+ )
274
+
275
+ return {
276
+ "status": True,
277
+ "embedding_engine": app.state.config.RAG_EMBEDDING_ENGINE,
278
+ "embedding_model": app.state.config.RAG_EMBEDDING_MODEL,
279
+ "openai_config": {
280
+ "url": app.state.config.OPENAI_API_BASE_URL,
281
+ "key": app.state.config.OPENAI_API_KEY,
282
+ },
283
+ }
284
+ except Exception as e:
285
+ log.exception(f"Problem updating embedding model: {e}")
286
+ raise HTTPException(
287
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
288
+ detail=ERROR_MESSAGES.DEFAULT(e),
289
+ )
290
+
291
+
292
+ class RerankingModelUpdateForm(BaseModel):
293
+ reranking_model: str
294
+
295
+
296
+ @app.post("/reranking/update")
297
+ async def update_reranking_config(
298
+ form_data: RerankingModelUpdateForm, user=Depends(get_admin_user)
299
+ ):
300
+ log.info(
301
+ f"Updating reranking model: {app.state.config.RAG_RERANKING_MODEL} to {form_data.reranking_model}"
302
+ )
303
+ try:
304
+ app.state.config.RAG_RERANKING_MODEL = form_data.reranking_model
305
+
306
+ update_reranking_model(app.state.config.RAG_RERANKING_MODEL), True
307
+
308
+ return {
309
+ "status": True,
310
+ "reranking_model": app.state.config.RAG_RERANKING_MODEL,
311
+ }
312
+ except Exception as e:
313
+ log.exception(f"Problem updating reranking model: {e}")
314
+ raise HTTPException(
315
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
316
+ detail=ERROR_MESSAGES.DEFAULT(e),
317
+ )
318
+
319
+
320
+ @app.get("/config")
321
+ async def get_rag_config(user=Depends(get_admin_user)):
322
+ return {
323
+ "status": True,
324
+ "pdf_extract_images": app.state.config.PDF_EXTRACT_IMAGES,
325
+ "chunk": {
326
+ "chunk_size": app.state.config.CHUNK_SIZE,
327
+ "chunk_overlap": app.state.config.CHUNK_OVERLAP,
328
+ },
329
+ "web_loader_ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
330
+ "youtube": {
331
+ "language": app.state.config.YOUTUBE_LOADER_LANGUAGE,
332
+ "translation": app.state.YOUTUBE_LOADER_TRANSLATION,
333
+ },
334
+ }
335
+
336
+
337
+ class ChunkParamUpdateForm(BaseModel):
338
+ chunk_size: int
339
+ chunk_overlap: int
340
+
341
+
342
+ class YoutubeLoaderConfig(BaseModel):
343
+ language: List[str]
344
+ translation: Optional[str] = None
345
+
346
+
347
+ class ConfigUpdateForm(BaseModel):
348
+ pdf_extract_images: Optional[bool] = None
349
+ chunk: Optional[ChunkParamUpdateForm] = None
350
+ web_loader_ssl_verification: Optional[bool] = None
351
+ youtube: Optional[YoutubeLoaderConfig] = None
352
+
353
+
354
+ @app.post("/config/update")
355
+ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_user)):
356
+ app.state.config.PDF_EXTRACT_IMAGES = (
357
+ form_data.pdf_extract_images
358
+ if form_data.pdf_extract_images is not None
359
+ else app.state.config.PDF_EXTRACT_IMAGES
360
+ )
361
+
362
+ app.state.config.CHUNK_SIZE = (
363
+ form_data.chunk.chunk_size
364
+ if form_data.chunk is not None
365
+ else app.state.config.CHUNK_SIZE
366
+ )
367
+
368
+ app.state.config.CHUNK_OVERLAP = (
369
+ form_data.chunk.chunk_overlap
370
+ if form_data.chunk is not None
371
+ else app.state.config.CHUNK_OVERLAP
372
+ )
373
+
374
+ app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
375
+ form_data.web_loader_ssl_verification
376
+ if form_data.web_loader_ssl_verification != None
377
+ else app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION
378
+ )
379
+
380
+ app.state.config.YOUTUBE_LOADER_LANGUAGE = (
381
+ form_data.youtube.language
382
+ if form_data.youtube is not None
383
+ else app.state.config.YOUTUBE_LOADER_LANGUAGE
384
+ )
385
+
386
+ app.state.YOUTUBE_LOADER_TRANSLATION = (
387
+ form_data.youtube.translation
388
+ if form_data.youtube is not None
389
+ else app.state.YOUTUBE_LOADER_TRANSLATION
390
+ )
391
+
392
+ return {
393
+ "status": True,
394
+ "pdf_extract_images": app.state.config.PDF_EXTRACT_IMAGES,
395
+ "chunk": {
396
+ "chunk_size": app.state.config.CHUNK_SIZE,
397
+ "chunk_overlap": app.state.config.CHUNK_OVERLAP,
398
+ },
399
+ "web_loader_ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
400
+ "youtube": {
401
+ "language": app.state.config.YOUTUBE_LOADER_LANGUAGE,
402
+ "translation": app.state.YOUTUBE_LOADER_TRANSLATION,
403
+ },
404
+ }
405
+
406
+
407
+ @app.get("/template")
408
+ async def get_rag_template(user=Depends(get_current_user)):
409
+ return {
410
+ "status": True,
411
+ "template": app.state.config.RAG_TEMPLATE,
412
+ }
413
+
414
+
415
+ @app.get("/query/settings")
416
+ async def get_query_settings(user=Depends(get_admin_user)):
417
+ return {
418
+ "status": True,
419
+ "template": app.state.config.RAG_TEMPLATE,
420
+ "k": app.state.config.TOP_K,
421
+ "r": app.state.config.RELEVANCE_THRESHOLD,
422
+ "hybrid": app.state.config.ENABLE_RAG_HYBRID_SEARCH,
423
+ }
424
+
425
+
426
+ class QuerySettingsForm(BaseModel):
427
+ k: Optional[int] = None
428
+ r: Optional[float] = None
429
+ template: Optional[str] = None
430
+ hybrid: Optional[bool] = None
431
+
432
+
433
+ @app.post("/query/settings/update")
434
+ async def update_query_settings(
435
+ form_data: QuerySettingsForm, user=Depends(get_admin_user)
436
+ ):
437
+ app.state.config.RAG_TEMPLATE = (
438
+ form_data.template if form_data.template else RAG_TEMPLATE
439
+ )
440
+ app.state.config.TOP_K = form_data.k if form_data.k else 4
441
+ app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0
442
+ app.state.config.ENABLE_RAG_HYBRID_SEARCH = (
443
+ form_data.hybrid if form_data.hybrid else False
444
+ )
445
+ return {
446
+ "status": True,
447
+ "template": app.state.config.RAG_TEMPLATE,
448
+ "k": app.state.config.TOP_K,
449
+ "r": app.state.config.RELEVANCE_THRESHOLD,
450
+ "hybrid": app.state.config.ENABLE_RAG_HYBRID_SEARCH,
451
+ }
452
+
453
+
454
+ class QueryDocForm(BaseModel):
455
+ collection_name: str
456
+ query: str
457
+ k: Optional[int] = None
458
+ r: Optional[float] = None
459
+ hybrid: Optional[bool] = None
460
+
461
+
462
+ @app.post("/query/doc")
463
+ def query_doc_handler(
464
+ form_data: QueryDocForm,
465
+ user=Depends(get_current_user),
466
+ ):
467
+ try:
468
+ if app.state.config.ENABLE_RAG_HYBRID_SEARCH:
469
+ return query_doc_with_hybrid_search(
470
+ collection_name=form_data.collection_name,
471
+ query=form_data.query,
472
+ embedding_function=app.state.EMBEDDING_FUNCTION,
473
+ k=form_data.k if form_data.k else app.state.config.TOP_K,
474
+ reranking_function=app.state.sentence_transformer_rf,
475
+ r=(
476
+ form_data.r if form_data.r else app.state.config.RELEVANCE_THRESHOLD
477
+ ),
478
+ )
479
+ else:
480
+ return query_doc(
481
+ collection_name=form_data.collection_name,
482
+ query=form_data.query,
483
+ embedding_function=app.state.EMBEDDING_FUNCTION,
484
+ k=form_data.k if form_data.k else app.state.config.TOP_K,
485
+ )
486
+ except Exception as e:
487
+ log.exception(e)
488
+ raise HTTPException(
489
+ status_code=status.HTTP_400_BAD_REQUEST,
490
+ detail=ERROR_MESSAGES.DEFAULT(e),
491
+ )
492
+
493
+
494
+ class QueryCollectionsForm(BaseModel):
495
+ collection_names: List[str]
496
+ query: str
497
+ k: Optional[int] = None
498
+ r: Optional[float] = None
499
+ hybrid: Optional[bool] = None
500
+
501
+
502
+ @app.post("/query/collection")
503
+ def query_collection_handler(
504
+ form_data: QueryCollectionsForm,
505
+ user=Depends(get_current_user),
506
+ ):
507
+ try:
508
+ if app.state.config.ENABLE_RAG_HYBRID_SEARCH:
509
+ return query_collection_with_hybrid_search(
510
+ collection_names=form_data.collection_names,
511
+ query=form_data.query,
512
+ embedding_function=app.state.EMBEDDING_FUNCTION,
513
+ k=form_data.k if form_data.k else app.state.config.TOP_K,
514
+ reranking_function=app.state.sentence_transformer_rf,
515
+ r=(
516
+ form_data.r if form_data.r else app.state.config.RELEVANCE_THRESHOLD
517
+ ),
518
+ )
519
+ else:
520
+ return query_collection(
521
+ collection_names=form_data.collection_names,
522
+ query=form_data.query,
523
+ embedding_function=app.state.EMBEDDING_FUNCTION,
524
+ k=form_data.k if form_data.k else app.state.config.TOP_K,
525
+ )
526
+
527
+ except Exception as e:
528
+ log.exception(e)
529
+ raise HTTPException(
530
+ status_code=status.HTTP_400_BAD_REQUEST,
531
+ detail=ERROR_MESSAGES.DEFAULT(e),
532
+ )
533
+
534
+
535
+ @app.post("/youtube")
536
+ def store_youtube_video(form_data: UrlForm, user=Depends(get_current_user)):
537
+ try:
538
+ loader = YoutubeLoader.from_youtube_url(
539
+ form_data.url,
540
+ add_video_info=True,
541
+ language=app.state.config.YOUTUBE_LOADER_LANGUAGE,
542
+ translation=app.state.YOUTUBE_LOADER_TRANSLATION,
543
+ )
544
+ data = loader.load()
545
+
546
+ collection_name = form_data.collection_name
547
+ if collection_name == "":
548
+ collection_name = calculate_sha256_string(form_data.url)[:63]
549
+
550
+ store_data_in_vector_db(data, collection_name, overwrite=True)
551
+ return {
552
+ "status": True,
553
+ "collection_name": collection_name,
554
+ "filename": form_data.url,
555
+ }
556
+ except Exception as e:
557
+ log.exception(e)
558
+ raise HTTPException(
559
+ status_code=status.HTTP_400_BAD_REQUEST,
560
+ detail=ERROR_MESSAGES.DEFAULT(e),
561
+ )
562
+
563
+
564
+ @app.post("/web")
565
+ def store_web(form_data: UrlForm, user=Depends(get_current_user)):
566
+ # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm"
567
+ try:
568
+ loader = get_web_loader(
569
+ form_data.url,
570
+ verify_ssl=app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
571
+ )
572
+ data = loader.load()
573
+
574
+ collection_name = form_data.collection_name
575
+ if collection_name == "":
576
+ collection_name = calculate_sha256_string(form_data.url)[:63]
577
+
578
+ store_data_in_vector_db(data, collection_name, overwrite=True)
579
+ return {
580
+ "status": True,
581
+ "collection_name": collection_name,
582
+ "filename": form_data.url,
583
+ }
584
+ except Exception as e:
585
+ log.exception(e)
586
+ raise HTTPException(
587
+ status_code=status.HTTP_400_BAD_REQUEST,
588
+ detail=ERROR_MESSAGES.DEFAULT(e),
589
+ )
590
+
591
+
592
+ def get_web_loader(url: str, verify_ssl: bool = True):
593
+ # Check if the URL is valid
594
+ if isinstance(validators.url(url), validators.ValidationError):
595
+ raise ValueError(ERROR_MESSAGES.INVALID_URL)
596
+ if not ENABLE_RAG_LOCAL_WEB_FETCH:
597
+ # Local web fetch is disabled, filter out any URLs that resolve to private IP addresses
598
+ parsed_url = urllib.parse.urlparse(url)
599
+ # Get IPv4 and IPv6 addresses
600
+ ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)
601
+ # Check if any of the resolved addresses are private
602
+ # This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader
603
+ for ip in ipv4_addresses:
604
+ if validators.ipv4(ip, private=True):
605
+ raise ValueError(ERROR_MESSAGES.INVALID_URL)
606
+ for ip in ipv6_addresses:
607
+ if validators.ipv6(ip, private=True):
608
+ raise ValueError(ERROR_MESSAGES.INVALID_URL)
609
+ return WebBaseLoader(url, verify_ssl=verify_ssl)
610
+
611
+
612
+ def resolve_hostname(hostname):
613
+ # Get address information
614
+ addr_info = socket.getaddrinfo(hostname, None)
615
+
616
+ # Extract IP addresses from address information
617
+ ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET]
618
+ ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6]
619
+
620
+ return ipv4_addresses, ipv6_addresses
621
+
622
+
623
+ def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> bool:
624
+
625
+ text_splitter = RecursiveCharacterTextSplitter(
626
+ chunk_size=app.state.config.CHUNK_SIZE,
627
+ chunk_overlap=app.state.config.CHUNK_OVERLAP,
628
+ add_start_index=True,
629
+ )
630
+
631
+ docs = text_splitter.split_documents(data)
632
+
633
+ if len(docs) > 0:
634
+ log.info(f"store_data_in_vector_db {docs}")
635
+ return store_docs_in_vector_db(docs, collection_name, overwrite), None
636
+ else:
637
+ raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
638
+
639
+
640
+ def store_text_in_vector_db(
641
+ text, metadata, collection_name, overwrite: bool = False
642
+ ) -> bool:
643
+ text_splitter = RecursiveCharacterTextSplitter(
644
+ chunk_size=app.state.config.CHUNK_SIZE,
645
+ chunk_overlap=app.state.config.CHUNK_OVERLAP,
646
+ add_start_index=True,
647
+ )
648
+ docs = text_splitter.create_documents([text], metadatas=[metadata])
649
+ return store_docs_in_vector_db(docs, collection_name, overwrite)
650
+
651
+
652
+ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> bool:
653
+ log.info(f"store_docs_in_vector_db {docs} {collection_name}")
654
+
655
+ texts = [doc.page_content for doc in docs]
656
+ metadatas = [doc.metadata for doc in docs]
657
+
658
+ try:
659
+ if overwrite:
660
+ for collection in CHROMA_CLIENT.list_collections():
661
+ if collection_name == collection.name:
662
+ log.info(f"deleting existing collection {collection_name}")
663
+ CHROMA_CLIENT.delete_collection(name=collection_name)
664
+
665
+ collection = CHROMA_CLIENT.create_collection(name=collection_name)
666
+
667
+ embedding_func = get_embedding_function(
668
+ app.state.config.RAG_EMBEDDING_ENGINE,
669
+ app.state.config.RAG_EMBEDDING_MODEL,
670
+ app.state.sentence_transformer_ef,
671
+ app.state.config.OPENAI_API_KEY,
672
+ app.state.config.OPENAI_API_BASE_URL,
673
+ )
674
+
675
+ embedding_texts = list(map(lambda x: x.replace("\n", " "), texts))
676
+ embeddings = embedding_func(embedding_texts)
677
+
678
+ for batch in create_batches(
679
+ api=CHROMA_CLIENT,
680
+ ids=[str(uuid.uuid4()) for _ in texts],
681
+ metadatas=metadatas,
682
+ embeddings=embeddings,
683
+ documents=texts,
684
+ ):
685
+ collection.add(*batch)
686
+
687
+ return True
688
+ except Exception as e:
689
+ log.exception(e)
690
+ if e.__class__.__name__ == "UniqueConstraintError":
691
+ return True
692
+
693
+ return False
694
+
695
+
696
+ def get_loader(filename: str, file_content_type: str, file_path: str):
697
+ file_ext = filename.split(".")[-1].lower()
698
+ known_type = True
699
+
700
+ known_source_ext = [
701
+ "go",
702
+ "py",
703
+ "java",
704
+ "sh",
705
+ "bat",
706
+ "ps1",
707
+ "cmd",
708
+ "js",
709
+ "ts",
710
+ "css",
711
+ "cpp",
712
+ "hpp",
713
+ "h",
714
+ "c",
715
+ "cs",
716
+ "sql",
717
+ "log",
718
+ "ini",
719
+ "pl",
720
+ "pm",
721
+ "r",
722
+ "dart",
723
+ "dockerfile",
724
+ "env",
725
+ "php",
726
+ "hs",
727
+ "hsc",
728
+ "lua",
729
+ "nginxconf",
730
+ "conf",
731
+ "m",
732
+ "mm",
733
+ "plsql",
734
+ "perl",
735
+ "rb",
736
+ "rs",
737
+ "db2",
738
+ "scala",
739
+ "bash",
740
+ "swift",
741
+ "vue",
742
+ "svelte",
743
+ ]
744
+
745
+ if file_ext == "pdf":
746
+ loader = PyPDFLoader(
747
+ file_path, extract_images=app.state.config.PDF_EXTRACT_IMAGES
748
+ )
749
+ elif file_ext == "csv":
750
+ loader = CSVLoader(file_path)
751
+ elif file_ext == "rst":
752
+ loader = UnstructuredRSTLoader(file_path, mode="elements")
753
+ elif file_ext == "xml":
754
+ loader = UnstructuredXMLLoader(file_path)
755
+ elif file_ext in ["htm", "html"]:
756
+ loader = BSHTMLLoader(file_path, open_encoding="unicode_escape")
757
+ elif file_ext == "md":
758
+ loader = UnstructuredMarkdownLoader(file_path)
759
+ elif file_content_type == "application/epub+zip":
760
+ loader = UnstructuredEPubLoader(file_path)
761
+ elif (
762
+ file_content_type
763
+ == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
764
+ or file_ext in ["doc", "docx"]
765
+ ):
766
+ loader = Docx2txtLoader(file_path)
767
+ elif file_content_type in [
768
+ "application/vnd.ms-excel",
769
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
770
+ ] or file_ext in ["xls", "xlsx"]:
771
+ loader = UnstructuredExcelLoader(file_path)
772
+ elif file_content_type in [
773
+ "application/vnd.ms-powerpoint",
774
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
775
+ ] or file_ext in ["ppt", "pptx"]:
776
+ loader = UnstructuredPowerPointLoader(file_path)
777
+ elif file_ext in known_source_ext or (
778
+ file_content_type and file_content_type.find("text/") >= 0
779
+ ):
780
+ loader = TextLoader(file_path, autodetect_encoding=True)
781
+ else:
782
+ loader = TextLoader(file_path, autodetect_encoding=True)
783
+ known_type = False
784
+
785
+ return loader, known_type
786
+
787
+
788
+ @app.post("/doc")
789
+ def store_doc(
790
+ collection_name: Optional[str] = Form(None),
791
+ file: UploadFile = File(...),
792
+ user=Depends(get_current_user),
793
+ ):
794
+ # "https://www.gutenberg.org/files/1727/1727-h/1727-h.htm"
795
+
796
+ log.info(f"file.content_type: {file.content_type}")
797
+ try:
798
+ unsanitized_filename = file.filename
799
+ filename = os.path.basename(unsanitized_filename)
800
+
801
+ file_path = f"{UPLOAD_DIR}/{filename}"
802
+
803
+ contents = file.file.read()
804
+ with open(file_path, "wb") as f:
805
+ f.write(contents)
806
+ f.close()
807
+
808
+ f = open(file_path, "rb")
809
+ if collection_name == None:
810
+ collection_name = calculate_sha256(f)[:63]
811
+ f.close()
812
+
813
+ loader, known_type = get_loader(filename, file.content_type, file_path)
814
+ data = loader.load()
815
+
816
+ try:
817
+ result = store_data_in_vector_db(data, collection_name)
818
+
819
+ if result:
820
+ return {
821
+ "status": True,
822
+ "collection_name": collection_name,
823
+ "filename": filename,
824
+ "known_type": known_type,
825
+ }
826
+ except Exception as e:
827
+ raise HTTPException(
828
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
829
+ detail=e,
830
+ )
831
+ except Exception as e:
832
+ log.exception(e)
833
+ if "No pandoc was found" in str(e):
834
+ raise HTTPException(
835
+ status_code=status.HTTP_400_BAD_REQUEST,
836
+ detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
837
+ )
838
+ else:
839
+ raise HTTPException(
840
+ status_code=status.HTTP_400_BAD_REQUEST,
841
+ detail=ERROR_MESSAGES.DEFAULT(e),
842
+ )
843
+
844
+
845
+ class TextRAGForm(BaseModel):
846
+ name: str
847
+ content: str
848
+ collection_name: Optional[str] = None
849
+
850
+
851
+ @app.post("/text")
852
+ def store_text(
853
+ form_data: TextRAGForm,
854
+ user=Depends(get_current_user),
855
+ ):
856
+
857
+ collection_name = form_data.collection_name
858
+ if collection_name == None:
859
+ collection_name = calculate_sha256_string(form_data.content)
860
+
861
+ result = store_text_in_vector_db(
862
+ form_data.content,
863
+ metadata={"name": form_data.name, "created_by": user.id},
864
+ collection_name=collection_name,
865
+ )
866
+
867
+ if result:
868
+ return {"status": True, "collection_name": collection_name}
869
+ else:
870
+ raise HTTPException(
871
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
872
+ detail=ERROR_MESSAGES.DEFAULT(),
873
+ )
874
+
875
+
876
+ @app.get("/scan")
877
+ def scan_docs_dir(user=Depends(get_admin_user)):
878
+ for path in Path(DOCS_DIR).rglob("./**/*"):
879
+ try:
880
+ if path.is_file() and not path.name.startswith("."):
881
+ tags = extract_folders_after_data_docs(path)
882
+ filename = path.name
883
+ file_content_type = mimetypes.guess_type(path)
884
+
885
+ f = open(path, "rb")
886
+ collection_name = calculate_sha256(f)[:63]
887
+ f.close()
888
+
889
+ loader, known_type = get_loader(
890
+ filename, file_content_type[0], str(path)
891
+ )
892
+ data = loader.load()
893
+
894
+ try:
895
+ result = store_data_in_vector_db(data, collection_name)
896
+
897
+ if result:
898
+ sanitized_filename = sanitize_filename(filename)
899
+ doc = Documents.get_doc_by_name(sanitized_filename)
900
+
901
+ if doc == None:
902
+ doc = Documents.insert_new_doc(
903
+ user.id,
904
+ DocumentForm(
905
+ **{
906
+ "name": sanitized_filename,
907
+ "title": filename,
908
+ "collection_name": collection_name,
909
+ "filename": filename,
910
+ "content": (
911
+ json.dumps(
912
+ {
913
+ "tags": list(
914
+ map(
915
+ lambda name: {"name": name},
916
+ tags,
917
+ )
918
+ )
919
+ }
920
+ )
921
+ if len(tags)
922
+ else "{}"
923
+ ),
924
+ }
925
+ ),
926
+ )
927
+ except Exception as e:
928
+ log.exception(e)
929
+ pass
930
+
931
+ except Exception as e:
932
+ log.exception(e)
933
+
934
+ return True
935
+
936
+
937
+ @app.get("/reset/db")
938
+ def reset_vector_db(user=Depends(get_admin_user)):
939
+ CHROMA_CLIENT.reset()
940
+
941
+
942
+ @app.get("/reset")
943
+ def reset(user=Depends(get_admin_user)) -> bool:
944
+ folder = f"{UPLOAD_DIR}"
945
+ for filename in os.listdir(folder):
946
+ file_path = os.path.join(folder, filename)
947
+ try:
948
+ if os.path.isfile(file_path) or os.path.islink(file_path):
949
+ os.unlink(file_path)
950
+ elif os.path.isdir(file_path):
951
+ shutil.rmtree(file_path)
952
+ except Exception as e:
953
+ log.error("Failed to delete %s. Reason: %s" % (file_path, e))
954
+
955
+ try:
956
+ CHROMA_CLIENT.reset()
957
+ except Exception as e:
958
+ log.exception(e)
959
+
960
+ return True
961
+
962
+
963
+ if ENV == "dev":
964
+
965
+ @app.get("/ef")
966
+ async def get_embeddings():
967
+ return {"result": app.state.EMBEDDING_FUNCTION("hello world")}
968
+
969
+ @app.get("/ef/{text}")
970
+ async def get_embeddings_text(text: str):
971
+ return {"result": app.state.EMBEDDING_FUNCTION(text)}
backend/apps/rag/utils.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import requests
4
+
5
+ from typing import List
6
+
7
+ from apps.ollama.main import (
8
+ generate_ollama_embeddings,
9
+ GenerateEmbeddingsForm,
10
+ )
11
+
12
+ from huggingface_hub import snapshot_download
13
+
14
+ from langchain_core.documents import Document
15
+ from langchain_community.retrievers import BM25Retriever
16
+ from langchain.retrievers import (
17
+ ContextualCompressionRetriever,
18
+ EnsembleRetriever,
19
+ )
20
+
21
+ from typing import Optional
22
+ from config import SRC_LOG_LEVELS, CHROMA_CLIENT
23
+
24
+
25
+ log = logging.getLogger(__name__)
26
+ log.setLevel(SRC_LOG_LEVELS["RAG"])
27
+
28
+
29
+ def query_doc(
30
+ collection_name: str,
31
+ query: str,
32
+ embedding_function,
33
+ k: int,
34
+ ):
35
+ try:
36
+ collection = CHROMA_CLIENT.get_collection(name=collection_name)
37
+ query_embeddings = embedding_function(query)
38
+
39
+ result = collection.query(
40
+ query_embeddings=[query_embeddings],
41
+ n_results=k,
42
+ )
43
+
44
+ log.info(f"query_doc:result {result}")
45
+ return result
46
+ except Exception as e:
47
+ raise e
48
+
49
+
50
+ def query_doc_with_hybrid_search(
51
+ collection_name: str,
52
+ query: str,
53
+ embedding_function,
54
+ k: int,
55
+ reranking_function,
56
+ r: float,
57
+ ):
58
+ try:
59
+ collection = CHROMA_CLIENT.get_collection(name=collection_name)
60
+ documents = collection.get() # get all documents
61
+
62
+ bm25_retriever = BM25Retriever.from_texts(
63
+ texts=documents.get("documents"),
64
+ metadatas=documents.get("metadatas"),
65
+ )
66
+ bm25_retriever.k = k
67
+
68
+ chroma_retriever = ChromaRetriever(
69
+ collection=collection,
70
+ embedding_function=embedding_function,
71
+ top_n=k,
72
+ )
73
+
74
+ ensemble_retriever = EnsembleRetriever(
75
+ retrievers=[bm25_retriever, chroma_retriever], weights=[0.5, 0.5]
76
+ )
77
+
78
+ compressor = RerankCompressor(
79
+ embedding_function=embedding_function,
80
+ top_n=k,
81
+ reranking_function=reranking_function,
82
+ r_score=r,
83
+ )
84
+
85
+ compression_retriever = ContextualCompressionRetriever(
86
+ base_compressor=compressor, base_retriever=ensemble_retriever
87
+ )
88
+
89
+ result = compression_retriever.invoke(query)
90
+ result = {
91
+ "distances": [[d.metadata.get("score") for d in result]],
92
+ "documents": [[d.page_content for d in result]],
93
+ "metadatas": [[d.metadata for d in result]],
94
+ }
95
+
96
+ log.info(f"query_doc_with_hybrid_search:result {result}")
97
+ return result
98
+ except Exception as e:
99
+ raise e
100
+
101
+
102
+ def merge_and_sort_query_results(query_results, k, reverse=False):
103
+ # Initialize lists to store combined data
104
+ combined_distances = []
105
+ combined_documents = []
106
+ combined_metadatas = []
107
+
108
+ for data in query_results:
109
+ combined_distances.extend(data["distances"][0])
110
+ combined_documents.extend(data["documents"][0])
111
+ combined_metadatas.extend(data["metadatas"][0])
112
+
113
+ # Create a list of tuples (distance, document, metadata)
114
+ combined = list(zip(combined_distances, combined_documents, combined_metadatas))
115
+
116
+ # Sort the list based on distances
117
+ combined.sort(key=lambda x: x[0], reverse=reverse)
118
+
119
+ # We don't have anything :-(
120
+ if not combined:
121
+ sorted_distances = []
122
+ sorted_documents = []
123
+ sorted_metadatas = []
124
+ else:
125
+ # Unzip the sorted list
126
+ sorted_distances, sorted_documents, sorted_metadatas = zip(*combined)
127
+
128
+ # Slicing the lists to include only k elements
129
+ sorted_distances = list(sorted_distances)[:k]
130
+ sorted_documents = list(sorted_documents)[:k]
131
+ sorted_metadatas = list(sorted_metadatas)[:k]
132
+
133
+ # Create the output dictionary
134
+ result = {
135
+ "distances": [sorted_distances],
136
+ "documents": [sorted_documents],
137
+ "metadatas": [sorted_metadatas],
138
+ }
139
+
140
+ return result
141
+
142
+
143
+ def query_collection(
144
+ collection_names: List[str],
145
+ query: str,
146
+ embedding_function,
147
+ k: int,
148
+ ):
149
+ results = []
150
+ for collection_name in collection_names:
151
+ try:
152
+ result = query_doc(
153
+ collection_name=collection_name,
154
+ query=query,
155
+ k=k,
156
+ embedding_function=embedding_function,
157
+ )
158
+ results.append(result)
159
+ except:
160
+ pass
161
+ return merge_and_sort_query_results(results, k=k)
162
+
163
+
164
+ def query_collection_with_hybrid_search(
165
+ collection_names: List[str],
166
+ query: str,
167
+ embedding_function,
168
+ k: int,
169
+ reranking_function,
170
+ r: float,
171
+ ):
172
+ results = []
173
+ for collection_name in collection_names:
174
+ try:
175
+ result = query_doc_with_hybrid_search(
176
+ collection_name=collection_name,
177
+ query=query,
178
+ embedding_function=embedding_function,
179
+ k=k,
180
+ reranking_function=reranking_function,
181
+ r=r,
182
+ )
183
+ results.append(result)
184
+ except:
185
+ pass
186
+ return merge_and_sort_query_results(results, k=k, reverse=True)
187
+
188
+
189
+ def rag_template(template: str, context: str, query: str):
190
+ template = template.replace("[context]", context)
191
+ template = template.replace("[query]", query)
192
+ return template
193
+
194
+
195
+ def get_embedding_function(
196
+ embedding_engine,
197
+ embedding_model,
198
+ embedding_function,
199
+ openai_key,
200
+ openai_url,
201
+ ):
202
+ if embedding_engine == "":
203
+ return lambda query: embedding_function.encode(query).tolist()
204
+ elif embedding_engine in ["ollama", "openai"]:
205
+ if embedding_engine == "ollama":
206
+ func = lambda query: generate_ollama_embeddings(
207
+ GenerateEmbeddingsForm(
208
+ **{
209
+ "model": embedding_model,
210
+ "prompt": query,
211
+ }
212
+ )
213
+ )
214
+ elif embedding_engine == "openai":
215
+ func = lambda query: generate_openai_embeddings(
216
+ model=embedding_model,
217
+ text=query,
218
+ key=openai_key,
219
+ url=openai_url,
220
+ )
221
+
222
+ def generate_multiple(query, f):
223
+ if isinstance(query, list):
224
+ return [f(q) for q in query]
225
+ else:
226
+ return f(query)
227
+
228
+ return lambda query: generate_multiple(query, func)
229
+
230
+
231
+ def rag_messages(
232
+ docs,
233
+ messages,
234
+ template,
235
+ embedding_function,
236
+ k,
237
+ reranking_function,
238
+ r,
239
+ hybrid_search,
240
+ ):
241
+ log.debug(f"docs: {docs} {messages} {embedding_function} {reranking_function}")
242
+
243
+ last_user_message_idx = None
244
+ for i in range(len(messages) - 1, -1, -1):
245
+ if messages[i]["role"] == "user":
246
+ last_user_message_idx = i
247
+ break
248
+
249
+ user_message = messages[last_user_message_idx]
250
+
251
+ if isinstance(user_message["content"], list):
252
+ # Handle list content input
253
+ content_type = "list"
254
+ query = ""
255
+ for content_item in user_message["content"]:
256
+ if content_item["type"] == "text":
257
+ query = content_item["text"]
258
+ break
259
+ elif isinstance(user_message["content"], str):
260
+ # Handle text content input
261
+ content_type = "text"
262
+ query = user_message["content"]
263
+ else:
264
+ # Fallback in case the input does not match expected types
265
+ content_type = None
266
+ query = ""
267
+
268
+ extracted_collections = []
269
+ relevant_contexts = []
270
+
271
+ for doc in docs:
272
+ context = None
273
+
274
+ collection_names = (
275
+ doc["collection_names"]
276
+ if doc["type"] == "collection"
277
+ else [doc["collection_name"]]
278
+ )
279
+
280
+ collection_names = set(collection_names).difference(extracted_collections)
281
+ if not collection_names:
282
+ log.debug(f"skipping {doc} as it has already been extracted")
283
+ continue
284
+
285
+ try:
286
+ if doc["type"] == "text":
287
+ context = doc["content"]
288
+ else:
289
+ if hybrid_search:
290
+ context = query_collection_with_hybrid_search(
291
+ collection_names=collection_names,
292
+ query=query,
293
+ embedding_function=embedding_function,
294
+ k=k,
295
+ reranking_function=reranking_function,
296
+ r=r,
297
+ )
298
+ else:
299
+ context = query_collection(
300
+ collection_names=collection_names,
301
+ query=query,
302
+ embedding_function=embedding_function,
303
+ k=k,
304
+ )
305
+ except Exception as e:
306
+ log.exception(e)
307
+ context = None
308
+
309
+ if context:
310
+ relevant_contexts.append({**context, "source": doc})
311
+
312
+ extracted_collections.extend(collection_names)
313
+
314
+ context_string = ""
315
+
316
+ citations = []
317
+ for context in relevant_contexts:
318
+ try:
319
+ if "documents" in context:
320
+ context_string += "\n\n".join(
321
+ [text for text in context["documents"][0] if text is not None]
322
+ )
323
+
324
+ if "metadatas" in context:
325
+ citations.append(
326
+ {
327
+ "source": context["source"],
328
+ "document": context["documents"][0],
329
+ "metadata": context["metadatas"][0],
330
+ }
331
+ )
332
+ except Exception as e:
333
+ log.exception(e)
334
+
335
+ context_string = context_string.strip()
336
+
337
+ ra_content = rag_template(
338
+ template=template,
339
+ context=context_string,
340
+ query=query,
341
+ )
342
+
343
+ log.debug(f"ra_content: {ra_content}")
344
+
345
+ if content_type == "list":
346
+ new_content = []
347
+ for content_item in user_message["content"]:
348
+ if content_item["type"] == "text":
349
+ # Update the text item's content with ra_content
350
+ new_content.append({"type": "text", "text": ra_content})
351
+ else:
352
+ # Keep other types of content as they are
353
+ new_content.append(content_item)
354
+ new_user_message = {**user_message, "content": new_content}
355
+ else:
356
+ new_user_message = {
357
+ **user_message,
358
+ "content": ra_content,
359
+ }
360
+
361
+ messages[last_user_message_idx] = new_user_message
362
+
363
+ return messages, citations
364
+
365
+
366
+ def get_model_path(model: str, update_model: bool = False):
367
+ # Construct huggingface_hub kwargs with local_files_only to return the snapshot path
368
+ cache_dir = os.getenv("SENTENCE_TRANSFORMERS_HOME")
369
+
370
+ local_files_only = not update_model
371
+
372
+ snapshot_kwargs = {
373
+ "cache_dir": cache_dir,
374
+ "local_files_only": local_files_only,
375
+ }
376
+
377
+ log.debug(f"model: {model}")
378
+ log.debug(f"snapshot_kwargs: {snapshot_kwargs}")
379
+
380
+ # Inspiration from upstream sentence_transformers
381
+ if (
382
+ os.path.exists(model)
383
+ or ("\\" in model or model.count("/") > 1)
384
+ and local_files_only
385
+ ):
386
+ # If fully qualified path exists, return input, else set repo_id
387
+ return model
388
+ elif "/" not in model:
389
+ # Set valid repo_id for model short-name
390
+ model = "sentence-transformers" + "/" + model
391
+
392
+ snapshot_kwargs["repo_id"] = model
393
+
394
+ # Attempt to query the huggingface_hub library to determine the local path and/or to update
395
+ try:
396
+ model_repo_path = snapshot_download(**snapshot_kwargs)
397
+ log.debug(f"model_repo_path: {model_repo_path}")
398
+ return model_repo_path
399
+ except Exception as e:
400
+ log.exception(f"Cannot determine model snapshot path: {e}")
401
+ return model
402
+
403
+
404
+ def generate_openai_embeddings(
405
+ model: str, text: str, key: str, url: str = "https://api.openai.com/v1"
406
+ ):
407
+ try:
408
+ r = requests.post(
409
+ f"{url}/embeddings",
410
+ headers={
411
+ "Content-Type": "application/json",
412
+ "Authorization": f"Bearer {key}",
413
+ },
414
+ json={"input": text, "model": model},
415
+ )
416
+ r.raise_for_status()
417
+ data = r.json()
418
+ if "data" in data:
419
+ return data["data"][0]["embedding"]
420
+ else:
421
+ raise "Something went wrong :/"
422
+ except Exception as e:
423
+ print(e)
424
+ return None
425
+
426
+
427
+ from typing import Any
428
+
429
+ from langchain_core.retrievers import BaseRetriever
430
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
431
+
432
+
433
+ class ChromaRetriever(BaseRetriever):
434
+ collection: Any
435
+ embedding_function: Any
436
+ top_n: int
437
+
438
+ def _get_relevant_documents(
439
+ self,
440
+ query: str,
441
+ *,
442
+ run_manager: CallbackManagerForRetrieverRun,
443
+ ) -> List[Document]:
444
+ query_embeddings = self.embedding_function(query)
445
+
446
+ results = self.collection.query(
447
+ query_embeddings=[query_embeddings],
448
+ n_results=self.top_n,
449
+ )
450
+
451
+ ids = results["ids"][0]
452
+ metadatas = results["metadatas"][0]
453
+ documents = results["documents"][0]
454
+
455
+ results = []
456
+ for idx in range(len(ids)):
457
+ results.append(
458
+ Document(
459
+ metadata=metadatas[idx],
460
+ page_content=documents[idx],
461
+ )
462
+ )
463
+ return results
464
+
465
+
466
+ import operator
467
+
468
+ from typing import Optional, Sequence
469
+
470
+ from langchain_core.documents import BaseDocumentCompressor, Document
471
+ from langchain_core.callbacks import Callbacks
472
+ from langchain_core.pydantic_v1 import Extra
473
+
474
+ from sentence_transformers import util
475
+
476
+
477
+ class RerankCompressor(BaseDocumentCompressor):
478
+ embedding_function: Any
479
+ top_n: int
480
+ reranking_function: Any
481
+ r_score: float
482
+
483
+ class Config:
484
+ extra = Extra.forbid
485
+ arbitrary_types_allowed = True
486
+
487
+ def compress_documents(
488
+ self,
489
+ documents: Sequence[Document],
490
+ query: str,
491
+ callbacks: Optional[Callbacks] = None,
492
+ ) -> Sequence[Document]:
493
+ reranking = self.reranking_function is not None
494
+
495
+ if reranking:
496
+ scores = self.reranking_function.predict(
497
+ [(query, doc.page_content) for doc in documents]
498
+ )
499
+ else:
500
+ query_embedding = self.embedding_function(query)
501
+ document_embedding = self.embedding_function(
502
+ [doc.page_content for doc in documents]
503
+ )
504
+ scores = util.cos_sim(query_embedding, document_embedding)[0]
505
+
506
+ docs_with_scores = list(zip(documents, scores.tolist()))
507
+ if self.r_score:
508
+ docs_with_scores = [
509
+ (d, s) for d, s in docs_with_scores if s >= self.r_score
510
+ ]
511
+
512
+ result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
513
+ final_results = []
514
+ for doc, doc_score in result[: self.top_n]:
515
+ metadata = doc.metadata
516
+ metadata["score"] = doc_score
517
+ doc = Document(
518
+ page_content=doc.page_content,
519
+ metadata=metadata,
520
+ )
521
+ final_results.append(doc)
522
+ return final_results
backend/apps/web/internal/db.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from peewee import *
2
+ from peewee_migrate import Router
3
+ from playhouse.db_url import connect
4
+ from config import SRC_LOG_LEVELS, DATA_DIR, DATABASE_URL
5
+ import os
6
+ import logging
7
+
8
+ log = logging.getLogger(__name__)
9
+ log.setLevel(SRC_LOG_LEVELS["DB"])
10
+
11
+ # Check if the file exists
12
+ if os.path.exists(f"{DATA_DIR}/ollama.db"):
13
+ # Rename the file
14
+ os.rename(f"{DATA_DIR}/ollama.db", f"{DATA_DIR}/webui.db")
15
+ log.info("Database migrated from Ollama-WebUI successfully.")
16
+ else:
17
+ pass
18
+
19
+ DB = connect(DATABASE_URL)
20
+ log.info(f"Connected to a {DB.__class__.__name__} database.")
21
+ router = Router(DB, migrate_dir="apps/web/internal/migrations", logger=log)
22
+ router.run()
23
+ DB.connect(reuse_if_open=True)
backend/apps/web/internal/migrations/001_initial_schema.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 001_initial_schema.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ """Write your migrations here."""
39
+
40
+ # We perform different migrations for SQLite and other databases
41
+ # This is because SQLite is very loose with enforcing its schema, and trying to migrate other databases like SQLite
42
+ # will require per-database SQL queries.
43
+ # Instead, we assume that because external DB support was added at a later date, it is safe to assume a newer base
44
+ # schema instead of trying to migrate from an older schema.
45
+ if isinstance(database, pw.SqliteDatabase):
46
+ migrate_sqlite(migrator, database, fake=fake)
47
+ else:
48
+ migrate_external(migrator, database, fake=fake)
49
+
50
+
51
+ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
52
+ @migrator.create_model
53
+ class Auth(pw.Model):
54
+ id = pw.CharField(max_length=255, unique=True)
55
+ email = pw.CharField(max_length=255)
56
+ password = pw.CharField(max_length=255)
57
+ active = pw.BooleanField()
58
+
59
+ class Meta:
60
+ table_name = "auth"
61
+
62
+ @migrator.create_model
63
+ class Chat(pw.Model):
64
+ id = pw.CharField(max_length=255, unique=True)
65
+ user_id = pw.CharField(max_length=255)
66
+ title = pw.CharField()
67
+ chat = pw.TextField()
68
+ timestamp = pw.BigIntegerField()
69
+
70
+ class Meta:
71
+ table_name = "chat"
72
+
73
+ @migrator.create_model
74
+ class ChatIdTag(pw.Model):
75
+ id = pw.CharField(max_length=255, unique=True)
76
+ tag_name = pw.CharField(max_length=255)
77
+ chat_id = pw.CharField(max_length=255)
78
+ user_id = pw.CharField(max_length=255)
79
+ timestamp = pw.BigIntegerField()
80
+
81
+ class Meta:
82
+ table_name = "chatidtag"
83
+
84
+ @migrator.create_model
85
+ class Document(pw.Model):
86
+ id = pw.AutoField()
87
+ collection_name = pw.CharField(max_length=255, unique=True)
88
+ name = pw.CharField(max_length=255, unique=True)
89
+ title = pw.CharField()
90
+ filename = pw.CharField()
91
+ content = pw.TextField(null=True)
92
+ user_id = pw.CharField(max_length=255)
93
+ timestamp = pw.BigIntegerField()
94
+
95
+ class Meta:
96
+ table_name = "document"
97
+
98
+ @migrator.create_model
99
+ class Modelfile(pw.Model):
100
+ id = pw.AutoField()
101
+ tag_name = pw.CharField(max_length=255, unique=True)
102
+ user_id = pw.CharField(max_length=255)
103
+ modelfile = pw.TextField()
104
+ timestamp = pw.BigIntegerField()
105
+
106
+ class Meta:
107
+ table_name = "modelfile"
108
+
109
+ @migrator.create_model
110
+ class Prompt(pw.Model):
111
+ id = pw.AutoField()
112
+ command = pw.CharField(max_length=255, unique=True)
113
+ user_id = pw.CharField(max_length=255)
114
+ title = pw.CharField()
115
+ content = pw.TextField()
116
+ timestamp = pw.BigIntegerField()
117
+
118
+ class Meta:
119
+ table_name = "prompt"
120
+
121
+ @migrator.create_model
122
+ class Tag(pw.Model):
123
+ id = pw.CharField(max_length=255, unique=True)
124
+ name = pw.CharField(max_length=255)
125
+ user_id = pw.CharField(max_length=255)
126
+ data = pw.TextField(null=True)
127
+
128
+ class Meta:
129
+ table_name = "tag"
130
+
131
+ @migrator.create_model
132
+ class User(pw.Model):
133
+ id = pw.CharField(max_length=255, unique=True)
134
+ name = pw.CharField(max_length=255)
135
+ email = pw.CharField(max_length=255)
136
+ role = pw.CharField(max_length=255)
137
+ profile_image_url = pw.CharField(max_length=255)
138
+ timestamp = pw.BigIntegerField()
139
+
140
+ class Meta:
141
+ table_name = "user"
142
+
143
+
144
+ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
145
+ @migrator.create_model
146
+ class Auth(pw.Model):
147
+ id = pw.CharField(max_length=255, unique=True)
148
+ email = pw.CharField(max_length=255)
149
+ password = pw.TextField()
150
+ active = pw.BooleanField()
151
+
152
+ class Meta:
153
+ table_name = "auth"
154
+
155
+ @migrator.create_model
156
+ class Chat(pw.Model):
157
+ id = pw.CharField(max_length=255, unique=True)
158
+ user_id = pw.CharField(max_length=255)
159
+ title = pw.TextField()
160
+ chat = pw.TextField()
161
+ timestamp = pw.BigIntegerField()
162
+
163
+ class Meta:
164
+ table_name = "chat"
165
+
166
+ @migrator.create_model
167
+ class ChatIdTag(pw.Model):
168
+ id = pw.CharField(max_length=255, unique=True)
169
+ tag_name = pw.CharField(max_length=255)
170
+ chat_id = pw.CharField(max_length=255)
171
+ user_id = pw.CharField(max_length=255)
172
+ timestamp = pw.BigIntegerField()
173
+
174
+ class Meta:
175
+ table_name = "chatidtag"
176
+
177
+ @migrator.create_model
178
+ class Document(pw.Model):
179
+ id = pw.AutoField()
180
+ collection_name = pw.CharField(max_length=255, unique=True)
181
+ name = pw.CharField(max_length=255, unique=True)
182
+ title = pw.TextField()
183
+ filename = pw.TextField()
184
+ content = pw.TextField(null=True)
185
+ user_id = pw.CharField(max_length=255)
186
+ timestamp = pw.BigIntegerField()
187
+
188
+ class Meta:
189
+ table_name = "document"
190
+
191
+ @migrator.create_model
192
+ class Modelfile(pw.Model):
193
+ id = pw.AutoField()
194
+ tag_name = pw.CharField(max_length=255, unique=True)
195
+ user_id = pw.CharField(max_length=255)
196
+ modelfile = pw.TextField()
197
+ timestamp = pw.BigIntegerField()
198
+
199
+ class Meta:
200
+ table_name = "modelfile"
201
+
202
+ @migrator.create_model
203
+ class Prompt(pw.Model):
204
+ id = pw.AutoField()
205
+ command = pw.CharField(max_length=255, unique=True)
206
+ user_id = pw.CharField(max_length=255)
207
+ title = pw.TextField()
208
+ content = pw.TextField()
209
+ timestamp = pw.BigIntegerField()
210
+
211
+ class Meta:
212
+ table_name = "prompt"
213
+
214
+ @migrator.create_model
215
+ class Tag(pw.Model):
216
+ id = pw.CharField(max_length=255, unique=True)
217
+ name = pw.CharField(max_length=255)
218
+ user_id = pw.CharField(max_length=255)
219
+ data = pw.TextField(null=True)
220
+
221
+ class Meta:
222
+ table_name = "tag"
223
+
224
+ @migrator.create_model
225
+ class User(pw.Model):
226
+ id = pw.CharField(max_length=255, unique=True)
227
+ name = pw.CharField(max_length=255)
228
+ email = pw.CharField(max_length=255)
229
+ role = pw.CharField(max_length=255)
230
+ profile_image_url = pw.TextField()
231
+ timestamp = pw.BigIntegerField()
232
+
233
+ class Meta:
234
+ table_name = "user"
235
+
236
+
237
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
238
+ """Write your rollback migrations here."""
239
+
240
+ migrator.remove_model("user")
241
+
242
+ migrator.remove_model("tag")
243
+
244
+ migrator.remove_model("prompt")
245
+
246
+ migrator.remove_model("modelfile")
247
+
248
+ migrator.remove_model("document")
249
+
250
+ migrator.remove_model("chatidtag")
251
+
252
+ migrator.remove_model("chat")
253
+
254
+ migrator.remove_model("auth")
backend/apps/web/internal/migrations/002_add_local_sharing.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 002_add_local_sharing.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ """Write your migrations here."""
39
+
40
+ migrator.add_fields(
41
+ "chat", share_id=pw.CharField(max_length=255, null=True, unique=True)
42
+ )
43
+
44
+
45
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
46
+ """Write your rollback migrations here."""
47
+
48
+ migrator.remove_fields("chat", "share_id")
backend/apps/web/internal/migrations/003_add_auth_api_key.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 002_add_local_sharing.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ """Write your migrations here."""
39
+
40
+ migrator.add_fields(
41
+ "user", api_key=pw.CharField(max_length=255, null=True, unique=True)
42
+ )
43
+
44
+
45
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
46
+ """Write your rollback migrations here."""
47
+
48
+ migrator.remove_fields("user", "api_key")
backend/apps/web/internal/migrations/004_add_archived.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 002_add_local_sharing.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ """Write your migrations here."""
39
+
40
+ migrator.add_fields("chat", archived=pw.BooleanField(default=False))
41
+
42
+
43
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
44
+ """Write your rollback migrations here."""
45
+
46
+ migrator.remove_fields("chat", "archived")
backend/apps/web/internal/migrations/005_add_updated_at.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 002_add_local_sharing.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ """Write your migrations here."""
39
+
40
+ if isinstance(database, pw.SqliteDatabase):
41
+ migrate_sqlite(migrator, database, fake=fake)
42
+ else:
43
+ migrate_external(migrator, database, fake=fake)
44
+
45
+
46
+ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
47
+ # Adding fields created_at and updated_at to the 'chat' table
48
+ migrator.add_fields(
49
+ "chat",
50
+ created_at=pw.DateTimeField(null=True), # Allow null for transition
51
+ updated_at=pw.DateTimeField(null=True), # Allow null for transition
52
+ )
53
+
54
+ # Populate the new fields from an existing 'timestamp' field
55
+ migrator.sql(
56
+ "UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL"
57
+ )
58
+
59
+ # Now that the data has been copied, remove the original 'timestamp' field
60
+ migrator.remove_fields("chat", "timestamp")
61
+
62
+ # Update the fields to be not null now that they are populated
63
+ migrator.change_fields(
64
+ "chat",
65
+ created_at=pw.DateTimeField(null=False),
66
+ updated_at=pw.DateTimeField(null=False),
67
+ )
68
+
69
+
70
+ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
71
+ # Adding fields created_at and updated_at to the 'chat' table
72
+ migrator.add_fields(
73
+ "chat",
74
+ created_at=pw.BigIntegerField(null=True), # Allow null for transition
75
+ updated_at=pw.BigIntegerField(null=True), # Allow null for transition
76
+ )
77
+
78
+ # Populate the new fields from an existing 'timestamp' field
79
+ migrator.sql(
80
+ "UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL"
81
+ )
82
+
83
+ # Now that the data has been copied, remove the original 'timestamp' field
84
+ migrator.remove_fields("chat", "timestamp")
85
+
86
+ # Update the fields to be not null now that they are populated
87
+ migrator.change_fields(
88
+ "chat",
89
+ created_at=pw.BigIntegerField(null=False),
90
+ updated_at=pw.BigIntegerField(null=False),
91
+ )
92
+
93
+
94
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
95
+ """Write your rollback migrations here."""
96
+
97
+ if isinstance(database, pw.SqliteDatabase):
98
+ rollback_sqlite(migrator, database, fake=fake)
99
+ else:
100
+ rollback_external(migrator, database, fake=fake)
101
+
102
+
103
+ def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
104
+ # Recreate the timestamp field initially allowing null values for safe transition
105
+ migrator.add_fields("chat", timestamp=pw.DateTimeField(null=True))
106
+
107
+ # Copy the earliest created_at date back into the new timestamp field
108
+ # This assumes created_at was originally a copy of timestamp
109
+ migrator.sql("UPDATE chat SET timestamp = created_at")
110
+
111
+ # Remove the created_at and updated_at fields
112
+ migrator.remove_fields("chat", "created_at", "updated_at")
113
+
114
+ # Finally, alter the timestamp field to not allow nulls if that was the original setting
115
+ migrator.change_fields("chat", timestamp=pw.DateTimeField(null=False))
116
+
117
+
118
+ def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False):
119
+ # Recreate the timestamp field initially allowing null values for safe transition
120
+ migrator.add_fields("chat", timestamp=pw.BigIntegerField(null=True))
121
+
122
+ # Copy the earliest created_at date back into the new timestamp field
123
+ # This assumes created_at was originally a copy of timestamp
124
+ migrator.sql("UPDATE chat SET timestamp = created_at")
125
+
126
+ # Remove the created_at and updated_at fields
127
+ migrator.remove_fields("chat", "created_at", "updated_at")
128
+
129
+ # Finally, alter the timestamp field to not allow nulls if that was the original setting
130
+ migrator.change_fields("chat", timestamp=pw.BigIntegerField(null=False))
backend/apps/web/internal/migrations/006_migrate_timestamps_and_charfields.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 006_migrate_timestamps_and_charfields.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ """Write your migrations here."""
39
+
40
+ # Alter the tables with timestamps
41
+ migrator.change_fields(
42
+ "chatidtag",
43
+ timestamp=pw.BigIntegerField(),
44
+ )
45
+ migrator.change_fields(
46
+ "document",
47
+ timestamp=pw.BigIntegerField(),
48
+ )
49
+ migrator.change_fields(
50
+ "modelfile",
51
+ timestamp=pw.BigIntegerField(),
52
+ )
53
+ migrator.change_fields(
54
+ "prompt",
55
+ timestamp=pw.BigIntegerField(),
56
+ )
57
+ migrator.change_fields(
58
+ "user",
59
+ timestamp=pw.BigIntegerField(),
60
+ )
61
+ # Alter the tables with varchar to text where necessary
62
+ migrator.change_fields(
63
+ "auth",
64
+ password=pw.TextField(),
65
+ )
66
+ migrator.change_fields(
67
+ "chat",
68
+ title=pw.TextField(),
69
+ )
70
+ migrator.change_fields(
71
+ "document",
72
+ title=pw.TextField(),
73
+ filename=pw.TextField(),
74
+ )
75
+ migrator.change_fields(
76
+ "prompt",
77
+ title=pw.TextField(),
78
+ )
79
+ migrator.change_fields(
80
+ "user",
81
+ profile_image_url=pw.TextField(),
82
+ )
83
+
84
+
85
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
86
+ """Write your rollback migrations here."""
87
+
88
+ if isinstance(database, pw.SqliteDatabase):
89
+ # Alter the tables with timestamps
90
+ migrator.change_fields(
91
+ "chatidtag",
92
+ timestamp=pw.DateField(),
93
+ )
94
+ migrator.change_fields(
95
+ "document",
96
+ timestamp=pw.DateField(),
97
+ )
98
+ migrator.change_fields(
99
+ "modelfile",
100
+ timestamp=pw.DateField(),
101
+ )
102
+ migrator.change_fields(
103
+ "prompt",
104
+ timestamp=pw.DateField(),
105
+ )
106
+ migrator.change_fields(
107
+ "user",
108
+ timestamp=pw.DateField(),
109
+ )
110
+ migrator.change_fields(
111
+ "auth",
112
+ password=pw.CharField(max_length=255),
113
+ )
114
+ migrator.change_fields(
115
+ "chat",
116
+ title=pw.CharField(),
117
+ )
118
+ migrator.change_fields(
119
+ "document",
120
+ title=pw.CharField(),
121
+ filename=pw.CharField(),
122
+ )
123
+ migrator.change_fields(
124
+ "prompt",
125
+ title=pw.CharField(),
126
+ )
127
+ migrator.change_fields(
128
+ "user",
129
+ profile_image_url=pw.CharField(),
130
+ )
backend/apps/web/internal/migrations/007_add_user_last_active_at.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 002_add_local_sharing.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ """Write your migrations here."""
39
+
40
+ # Adding fields created_at and updated_at to the 'user' table
41
+ migrator.add_fields(
42
+ "user",
43
+ created_at=pw.BigIntegerField(null=True), # Allow null for transition
44
+ updated_at=pw.BigIntegerField(null=True), # Allow null for transition
45
+ last_active_at=pw.BigIntegerField(null=True), # Allow null for transition
46
+ )
47
+
48
+ # Populate the new fields from an existing 'timestamp' field
49
+ migrator.sql(
50
+ 'UPDATE "user" SET created_at = timestamp, updated_at = timestamp, last_active_at = timestamp WHERE timestamp IS NOT NULL'
51
+ )
52
+
53
+ # Now that the data has been copied, remove the original 'timestamp' field
54
+ migrator.remove_fields("user", "timestamp")
55
+
56
+ # Update the fields to be not null now that they are populated
57
+ migrator.change_fields(
58
+ "user",
59
+ created_at=pw.BigIntegerField(null=False),
60
+ updated_at=pw.BigIntegerField(null=False),
61
+ last_active_at=pw.BigIntegerField(null=False),
62
+ )
63
+
64
+
65
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
66
+ """Write your rollback migrations here."""
67
+
68
+ # Recreate the timestamp field initially allowing null values for safe transition
69
+ migrator.add_fields("user", timestamp=pw.BigIntegerField(null=True))
70
+
71
+ # Copy the earliest created_at date back into the new timestamp field
72
+ # This assumes created_at was originally a copy of timestamp
73
+ migrator.sql('UPDATE "user" SET timestamp = created_at')
74
+
75
+ # Remove the created_at and updated_at fields
76
+ migrator.remove_fields("user", "created_at", "updated_at", "last_active_at")
77
+
78
+ # Finally, alter the timestamp field to not allow nulls if that was the original setting
79
+ migrator.change_fields("user", timestamp=pw.BigIntegerField(null=False))
backend/apps/web/internal/migrations/008_add_memory.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peewee migrations -- 002_add_local_sharing.py.
2
+
3
+ Some examples (model - class or model name)::
4
+
5
+ > Model = migrator.orm['table_name'] # Return model in current state by name
6
+ > Model = migrator.ModelClass # Return model in current state by name
7
+
8
+ > migrator.sql(sql) # Run custom SQL
9
+ > migrator.run(func, *args, **kwargs) # Run python function with the given args
10
+ > migrator.create_model(Model) # Create a model (could be used as decorator)
11
+ > migrator.remove_model(model, cascade=True) # Remove a model
12
+ > migrator.add_fields(model, **fields) # Add fields to a model
13
+ > migrator.change_fields(model, **fields) # Change fields
14
+ > migrator.remove_fields(model, *field_names, cascade=True)
15
+ > migrator.rename_field(model, old_field_name, new_field_name)
16
+ > migrator.rename_table(model, new_table_name)
17
+ > migrator.add_index(model, *col_names, unique=False)
18
+ > migrator.add_not_null(model, *field_names)
19
+ > migrator.add_default(model, field_name, default)
20
+ > migrator.add_constraint(model, name, sql)
21
+ > migrator.drop_index(model, *col_names)
22
+ > migrator.drop_not_null(model, *field_names)
23
+ > migrator.drop_constraints(model, *constraints)
24
+
25
+ """
26
+
27
+ from contextlib import suppress
28
+
29
+ import peewee as pw
30
+ from peewee_migrate import Migrator
31
+
32
+
33
+ with suppress(ImportError):
34
+ import playhouse.postgres_ext as pw_pext
35
+
36
+
37
+ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
38
+ @migrator.create_model
39
+ class Memory(pw.Model):
40
+ id = pw.CharField(max_length=255, unique=True)
41
+ user_id = pw.CharField(max_length=255)
42
+ content = pw.TextField(null=False)
43
+ updated_at = pw.BigIntegerField(null=False)
44
+ created_at = pw.BigIntegerField(null=False)
45
+
46
+ class Meta:
47
+ table_name = "memory"
48
+
49
+
50
+ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
51
+ """Write your rollback migrations here."""
52
+
53
+ migrator.remove_model("memory")