Compare commits

...

399 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek 568dbc545c Merge pull request #8351 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.5
2025-01-22 11:34:03 -08:00
Timothy Jaeryang Baek b641756020 refac: disable duplicate attachments 2025-01-22 11:28:54 -08:00
Timothy Jaeryang Baek c6660f1d7c refac: favicon 2025-01-22 11:27:03 -08:00
Timothy Jaeryang Baek 195e632ed4 chore: logo 2025-01-22 11:19:42 -08:00
Timothy Jaeryang Baek b7f743d24c refac: favicon styling 2025-01-22 11:11:12 -08:00
Timothy Jaeryang Baek 1080ecd22c refac 2025-01-22 11:06:06 -08:00
Timothy Jaeryang Baek b5bce9cbea fix: file attachment 2025-01-22 11:04:12 -08:00
Timothy Jaeryang Baek 647195c578 chore: format 2025-01-22 10:51:29 -08:00
Timothy Jaeryang Baek ab4372600c chore: bump duckduckgo-search 2025-01-22 10:46:57 -08:00
Timothy Jaeryang Baek 8114223b54 infra: disable integration test 2025-01-22 10:43:35 -08:00
Timothy Jaeryang Baek a1a658d55b chore: dependecies 2025-01-22 10:43:08 -08:00
Timothy Jaeryang Baek 6eb4c0aecd doc: changelog 2025-01-22 10:40:48 -08:00
Timothy Jaeryang Baek 8b998420fe refac 2025-01-22 10:12:09 -08:00
Timothy Jaeryang Baek c2b5200663 refac 2025-01-22 09:42:40 -08:00
Timothy Jaeryang Baek ed7db1dd41 enh: 'notification' event type 2025-01-22 09:38:44 -08:00
Timothy Jaeryang Baek a1e4e7c007 refac: omit reasoning content in payload 2025-01-22 09:29:26 -08:00
Timothy Jaeryang Baek 9feed97f22 refac: think tag 2025-01-22 09:24:40 -08:00
Timothy Jaeryang Baek cf470e70e2 Merge pull request #8743 from open-webui/dependabot/npm_and_yarn/npm_and_yarn-37a58caa2a
build(deps): bump the npm_and_yarn group across 1 directory with 2 updates
2025-01-22 01:10:21 -08:00
Timothy Jaeryang Baek 2b1c2942a8 refac: message timestamp 2025-01-22 01:02:52 -08:00
Timothy Jaeryang Baek 10ffbca34b refac: more reasoning tags support 2025-01-22 00:56:55 -08:00
Timothy Jaeryang Baek d42f811a8d refac: think tag 2025-01-22 00:49:41 -08:00
Timothy Jaeryang Baek 039d685547 refac 2025-01-22 00:32:09 -08:00
Timothy Jaeryang Baek 2777d3ec49 refac: think tag styling 2025-01-22 00:23:47 -08:00
Timothy Jaeryang Baek c9dc7299c5 enh: <think> tag support 2025-01-22 00:13:24 -08:00
dependabot[bot] 0e6c680db8 build(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [katex](https://github.com/KaTeX/KaTeX) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `katex` from 0.16.10 to 0.16.21
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.10...v0.16.21)

Updates `vite` from 5.4.6 to 5.4.14
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: katex
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-22 06:16:45 +00:00
Timothy Jaeryang Baek 8d3c73aed5 chore: format backend 2025-01-21 19:33:33 -08:00
Timothy Jaeryang Baek 4c86f39a84 chore: format 2025-01-21 19:33:26 -08:00
Timothy Jaeryang Baek 35547c4246 Merge pull request #8736 from aqiray/dev
i18n: Update Turkish Translation
2025-01-21 19:32:20 -08:00
Giray a839808991 i18n: Update Turkish Translation 2025-01-22 05:29:46 +03:00
Timothy Jaeryang Baek 80e004c31f Merge pull request #8637 from kahghi/add-gcs-storage-provider
feat:add GCSStorageProvider
2025-01-21 18:14:22 -08:00
Timothy Jaeryang Baek ffd10e8b6f Merge pull request #8712 from rragundez/improve-s3-provider-tests
Improve s3 provider tests
2025-01-21 11:35:46 -08:00
Timothy Jaeryang Baek 8cca31e6fe Merge pull request #8711 from KarlLee830/translate
i18n: Update Chinese Translation
2025-01-21 11:34:25 -08:00
Rodrigo Agundez bdc6082221 Move patchin of S3 obects inside the init 2025-01-21 17:21:54 +08:00
kahghi a93124cafb tidied up test fixture for gcp storage emulator 2025-01-21 17:19:56 +08:00
KarlLee830 b2178b982d i18n: Update Chinese Translation 2025-01-21 15:50:16 +08:00
Timothy Jaeryang Baek 6408452e8f refac: model editor 2025-01-20 23:44:47 -08:00
kahghi 4aa9b8d5e7 removed unused pytest-env and imports 2025-01-21 15:38:41 +08:00
Timothy Jaeryang Baek 94d0f84c0c refac: styling 2025-01-20 23:26:43 -08:00
Timothy Jaeryang Baek 45f4bc18f8 refac: access controls 2025-01-20 23:20:47 -08:00
Timothy Jaeryang Baek aa442f694b enh: validate user id before saving group 2025-01-20 23:09:55 -08:00
Timothy Jaeryang Baek 31ed1fcdb8 fix: tools permissions 2025-01-20 22:57:40 -08:00
Timothy Jaeryang Baek 7a70fd1312 fix: bing search 2025-01-20 22:52:19 -08:00
kahghi b1887fef1b added suggestions 2025-01-21 14:47:56 +08:00
Timothy Jaeryang Baek 006208f9a9 enh: title edit should auto focus to input 2025-01-20 22:44:59 -08:00
Timothy Jaeryang Baek a863f98c53 refac: toast error 2025-01-20 22:41:32 -08:00
Timothy Jaeryang Baek af48f346f1 enh: reintroduce model management to models settings 2025-01-20 22:39:00 -08:00
kahghi 55cc127b03 gcs tests pass, updated get_file and delete_file to retrieve blob instead of instantiating blob 2025-01-21 12:53:49 +08:00
kahghi 4b56c15a3f test_upload_file working, added gcp-storage-emulator, updated gcs client instantiation 2025-01-20 22:35:43 +08:00
kahghi 44574c434d removed unnecessary GCS_PROJECT_ID variable 2025-01-20 15:27:55 +08:00
Timothy Jaeryang Baek 59c349de00 refac: styling 2025-01-19 12:46:02 -08:00
Timothy Jaeryang Baek bdc60e7850 chore: format backend 2025-01-19 11:59:07 -08:00
Timothy Jaeryang Baek a1e33a82f0 chore: format 2025-01-19 11:58:58 -08:00
Timothy Jaeryang Baek 9a794b1f8f refac 2025-01-19 11:49:51 -08:00
Timothy Jaeryang Baek 149eea2377 refac: chat controls 2025-01-19 11:49:16 -08:00
Timothy Jaeryang Baek db8b3d29da refac: app styling 2025-01-19 11:48:05 -08:00
Timothy Jaeryang Baek 6b7fe4f81a Merge pull request #8683 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-01-19 11:47:42 -08:00
Timothy Jaeryang Baek e240b95026 Merge pull request #8675 from jyje/i18n/ko-KR
i18n: update ko-KR for settings and admin settings
2025-01-19 11:47:01 -08:00
Simon 475568d9e7 Update translation.json 2025-01-19 19:32:16 +01:00
Simon fd3348d564 Update translation.json 2025-01-19 19:25:15 +01:00
kahghi 49f31ddcd8 added alternative storage client instantiation method, corrected filepaths, added missing type hinting 2025-01-19 22:42:29 +08:00
jyje 4fa234b6e9 i18n: fix ko-KR for awkward words 2025-01-19 19:57:08 +09:00
jyje 04b646eac0 i18n: update ko-KR for 'admin settings audio'
- admin Settings/Audio
2025-01-19 19:49:31 +09:00
jyje 5e65471560 i18n: update ko-KR for 'admin settings interface' 2025-01-19 19:40:29 +09:00
jyje a98a84859e i18n: update ko-KR for 'admin settings web search'
- admin Settings/WebSearch
2025-01-19 19:30:14 +09:00
jyje 39bc76cbc4 i18n: update ko-KR for 'admin settings documents'
- admin Settings/Documents
2025-01-19 18:59:07 +09:00
jyje 03d54f97ab i18n: update ko-KR for 'admin settings evaluations'
- Settings/Evaulations/ArenaModelModal
- Workspace/Common/AccessControl
2025-01-19 17:33:04 +09:00
jyje 568b873fff i18n: update ko-KR for admin Settings/Models, Settings/Models/ConfigureModelsModal 2025-01-19 17:14:28 +09:00
jyje 09827064fe i18n: update ko-KR for admin Settings/Connections 2025-01-19 17:08:18 +09:00
jyje c26e7820e1 i18n: update ko-KR for admin Settings/General 2025-01-19 17:00:39 +09:00
jyje 0a6ba21e57 i18n: update ko-KR for Settings/Account 2025-01-19 16:47:43 +09:00
jyje 42db2748e6 i18n: update ko-KR for Settings/General, Settings/Advanced 2025-01-19 16:37:34 +09:00
Timothy Jaeryang Baek 3fefbb9e20 refac 2025-01-18 17:50:03 -08:00
Timothy Jaeryang Baek 71ab8b9c93 enh: allow connections with duplicate urls 2025-01-18 17:22:29 -08:00
Timothy Jaeryang Baek ca0285fc91 refac: connections handling 2025-01-18 17:10:15 -08:00
Timothy Jaeryang Baek 430854e223 refac 2025-01-18 16:34:13 -08:00
Timothy Jaeryang Baek 43c4b8e518 fix: ollama connections 2025-01-18 16:28:39 -08:00
Timothy Jaeryang Baek 2b66a7a8dc Merge pull request #8670 from SlavikCA/main
docs: Update incorrect hint about `num_gpu` parameter
2025-01-18 15:42:10 -08:00
slavik.fursov c7c3c4a23c Update incorrect hint about num_gpu parameter 2025-01-18 14:22:01 -08:00
Timothy Jaeryang Baek 27f1ed18f7 Merge pull request #8668 from jyje/i18n/ko-KR
i18n: update ko-KR
2025-01-18 11:07:18 -08:00
jyje 66796b38bc i18n: update ko-KR for Settings/Interface, Messages/RateComment and etc 2025-01-19 02:11:39 +09:00
Timothy Jaeryang Baek f5437dae4a refac: electron app 2025-01-17 17:05:52 -08:00
Timothy Jaeryang Baek 70aced7f58 refac: styling 2025-01-17 17:03:25 -08:00
Timothy Jaeryang Baek 2535e1b53a Merge pull request #8659 from open-webui/dependabot/npm_and_yarn/npm_and_yarn-7064c9a8ac
build(deps): bump katex from 0.16.10 to 0.16.21 in the npm_and_yarn group across 1 directory
2025-01-17 15:25:45 -08:00
Timothy Jaeryang Baek 7519f683ed Merge pull request #8660 from jk-f5/fix/decode
Remove unnecessary decode statement from release_lock method
2025-01-17 15:25:36 -08:00
Jason Kidd c5b67ea430 fix: Remove unnecessary decode statement
Since we create our Redis instance with , we don't
need to worry about decoding this value here. Plus this doesn't work
in python3 anyway
2025-01-17 13:43:56 -08:00
dependabot[bot] 1827eaca2d build(deps): bump katex in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [katex](https://github.com/KaTeX/KaTeX).


Updates `katex` from 0.16.10 to 0.16.21
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.10...v0.16.21)

---
updated-dependencies:
- dependency-name: katex
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-17 21:34:21 +00:00
Timothy Jaeryang Baek 217e3a13c8 feat: Add ability to change permissions on group creation API 2025-01-17 12:03:24 -08:00
Timothy Jaeryang Baek 2818f3cf95 Merge pull request #8610 from excho0/patch-1
🌟 Chore: Add Svelte Inspector for Easier Development 🌟
2025-01-17 10:30:03 -08:00
Timothy Jaeryang Baek 3994b1c6f7 Merge pull request #8623 from Tryanks/autocompletion
feat: non-english (chinese) autocompletion handling
2025-01-17 10:28:32 -08:00
Timothy Jaeryang Baek 382dece228 Merge pull request #8638 from Tryanks/oauth2-github
refactor: Extend OIDC support to all OAuth authentication methods
2025-01-17 10:27:49 -08:00
kahghi 1764de41f3 updated to use credentials json, tested with local built image and upload to gcs works 2025-01-17 21:53:41 +08:00
kahghi 8efc543f49 added google-cloud-storage package, added gcs related functions 2025-01-17 16:16:25 +08:00
Tryanks e7971b5840 enh: add github oauth2 provider support 2025-01-17 13:22:35 +08:00
Tryanks f3e6dacf0d refac: Extend OIDC support to all OAuth authentication methods 2025-01-17 12:56:03 +08:00
kahghi 42b7beb8a7 add GCSStorageProvider 2025-01-17 09:39:59 +08:00
Timothy Jaeryang Baek 7cf4c9c89c refac: comfyui 2025-01-16 11:17:37 -08:00
Timothy Jaeryang Baek 5526c43853 Merge pull request #8620 from Tryanks/dev
fix: incorrectly indexing the key userinfo in the token.
2025-01-16 11:12:41 -08:00
Timothy Jaeryang Baek eb17cf6915 Merge pull request #8608 from rragundez/hotfix-get-picture
Hotfix get picture
2025-01-16 11:12:18 -08:00
Timothy Jaeryang Baek 4645943066 refac: file handler 2025-01-16 11:11:23 -08:00
Tryanks 612ca54afa enh: non-english (chinese) autocompletion handling 2025-01-17 01:50:23 +08:00
Tryanks 4b6700a4b2 fixed: incorrectly indexing the key userinfo in the token. 2025-01-17 00:33:20 +08:00
Excho 8468085291 Update svelte.config.js
Added svelte inspector for easier development
2025-01-16 14:29:00 +02:00
Rodrigo Agundez 91df1c56b2 Add headers 2025-01-16 19:32:35 +08:00
Timothy Jaeryang Baek 0425621494 refac 2025-01-16 00:13:02 -08:00
Timothy Jaeryang Baek 0360aa5520 enh: image prompt enhancer 2025-01-16 00:06:37 -08:00
Timothy Jaeryang Baek d3a5b9c127 refac: styling 2025-01-15 23:39:29 -08:00
Timothy Jaeryang Baek a10302d909 enh: image generation toggle 2025-01-15 23:32:13 -08:00
Timothy Jaeryang Baek 92022dd81f enh: image permissions 2025-01-15 23:12:40 -08:00
Timothy Jaeryang Baek 90d4bedae8 enh: ability to set chat controls permissions 2025-01-15 23:08:54 -08:00
Timothy Jaeryang Baek 56f57928c2 enh/refac: permissions 2025-01-15 23:01:43 -08:00
Timothy Jaeryang Baek 2aa82d98cc refac: styling 2025-01-15 21:51:11 -08:00
Timothy Jaeryang Baek 8b3fb2a8b5 Merge pull request #8580 from rragundez/split-storage-providers
Split the storage providers into separate classes in preparation for adding more cloud providers
2025-01-15 21:08:04 -08:00
Rodrigo Agundez f14467c65e Update dependencies to include mock 2025-01-16 13:00:17 +08:00
Rodrigo Agundez 544243fabb Add moto as a dependency 2025-01-16 12:37:54 +08:00
Rodrigo Agundez 021c1f3900 Add test for S3 using moto 2025-01-16 12:37:44 +08:00
Rodrigo Agundez 357e7dd20f Add tests for local provider 2025-01-16 11:13:10 +08:00
Rodrigo Agundez 3aa28de5f1 Add test classes for the providers 2025-01-16 08:31:59 +08:00
Rodrigo Agundez 535e59af8d Start testing files for provider 2025-01-16 08:10:17 +08:00
Rodrigo Agundez dd16c3d5c1 add function to retrieve the storage provider so it can be easily tested 2025-01-16 08:09:56 +08:00
Rodrigo Agundez 0129255478 Set default storage provider to local 2025-01-16 08:01:26 +08:00
Rodrigo Agundez a3f737c0c6 Split the storage providers into separate classes in preparation for other storage providers like GCS 2025-01-16 07:50:12 +08:00
Timothy Jaeryang Baek 372658be6d Merge pull request #8547 from juananpe/file-deletion
fix: File deletion doesn't properly clean up database entries
2025-01-15 10:11:29 -08:00
Timothy Jaeryang Baek d1bde9f348 Merge pull request #8562 from NYU-ITS/milvus_custom_db
[feat] Milvus: add new config var, MILVUS_DB
2025-01-15 10:10:10 -08:00
Timothy Jaeryang Baek b797112322 Merge pull request #8565 from mykola-mmm/fix/show_user_chats_from_folders
Fix: updated get_chat_list_by_user_id method to return all user cha…
2025-01-15 10:09:43 -08:00
Timothy Jaeryang Baek 3328e2ea97 Merge pull request #8566 from rragundez/ms-auth-picture
Add functionality to retrive picture for microsoft oauth
2025-01-15 10:02:28 -08:00
Timothy Jaeryang Baek 0775ddbdf5 Merge pull request #8585 from TiancongLx/dev
i18n: update zh-TW
2025-01-15 09:53:34 -08:00
Tiancong Li 6e088df173 i18n: update zh-TW 2025-01-16 01:05:26 +08:00
Timothy Jaeryang Baek 5e548fa7b8 refac: styling 2025-01-14 23:53:19 -08:00
Rodrigo Agundez 32f121f019 Add functionality to retrive picture for microsoft oauth 2025-01-15 09:18:16 +08:00
Rodrigo Agundez c8be0b20cc Add functionality to retrive picture for microsoft oauth 2025-01-15 09:17:22 +08:00
mykola b1568878e7 Fixed: updated get_chat_list_by_user_id method to return all user chats (including those moved to the folders) 2025-01-14 23:15:56 +00:00
Sajid Ali 7a95df008e Milvus: add new config var, MILVUS_DB
modified:   backend/open_webui/config.py
	modified:   backend/open_webui/retrieval/vector/dbs/milvus.py
2025-01-14 15:48:15 -05:00
Juanan Pereira f477f4f790 fix: fixess issue #7181 2025-01-14 09:32:04 +01:00
Timothy Jaeryang Baek f6a54c96bc Merge pull request #8470 from tarmst/add-read-write-toggle-to-access-control
feat: Add toggle to read/write perms on access control
2025-01-13 21:22:36 -08:00
Timothy Jaeryang Baek bb261fcc4a refac: styling 2025-01-13 21:20:31 -08:00
Timothy Jaeryang Baek 4044d80024 fix 2025-01-13 21:15:59 -08:00
Timothy Jaeryang Baek 3a3764bf68 refac: drag region 2025-01-13 21:15:22 -08:00
Timothy Jaeryang Baek c4486b4a77 refac: styling 2025-01-13 21:13:16 -08:00
Timothy Jaeryang Baek ac5bdac161 refac: styling 2025-01-13 20:21:47 -08:00
Timothy Jaeryang Baek ce1712fed1 fix: electron api 2025-01-13 19:53:00 -08:00
Timothy Jaeryang Baek 15e591f30d enh: electron window focus detect 2025-01-13 19:47:24 -08:00
Timothy Jaeryang Baek 84b7583982 refac: styling 2025-01-13 19:39:18 -08:00
Timothy Jaeryang Baek 4fcf0caa4d refac 2025-01-13 19:25:07 -08:00
Timothy Jaeryang Baek 22ad0264c1 refac: styling 2025-01-13 19:23:57 -08:00
Timothy Jaeryang Baek f299e19002 refac: styling 2025-01-13 19:18:32 -08:00
Timothy Jaeryang Baek 147eb0d2d1 refac: electron drag region 2025-01-13 18:19:46 -08:00
Timothy Jaeryang Baek 44da86895d refac: styling 2025-01-13 18:08:17 -08:00
Timothy Jaeryang Baek eb99785047 Merge pull request #8450 from d4v3y0rk/fix-integration-test-error
ci: fix for cypress error in integration test
2025-01-13 13:16:57 -08:00
Timothy Jaeryang Baek d83acb2f03 Merge pull request #8510 from MadsLang/main
Add DATABASE_SCHEMA as env var
2025-01-13 13:16:06 -08:00
Timothy Jaeryang Baek b6128efb43 chore: bump 2025-01-13 00:34:44 -08:00
Timothy Jaeryang Baek ad93341b74 refac 2025-01-13 00:34:15 -08:00
MadsLang d4a26f8031 Merge branch 'open-webui:main' into main 2025-01-13 08:28:13 +01:00
Timothy Jaeryang Baek 42e8d8ce49 Merge pull request #8447 from steelcg/dev
feat: add LDAP_ATTRIBUTE_FOR_MAIL to env-configuration
2025-01-12 18:15:28 -08:00
Timothy Jaeryang Baek 8e08c218c5 Merge pull request #8499 from juananpe/file-cleanup-knowledge
fix: complete file cleanup when removing from Knowledge Base
2025-01-12 18:14:48 -08:00
Timothy Jaeryang Baek 3e7036151e Merge pull request #8490 from flefevre/i18n-fr-FR
i18n - Updated French Translation
2025-01-12 12:03:54 -08:00
Juanan Pereira 913e0d391b fix: complete file cleanup when removing from Knowledge Base 2025-01-12 20:04:25 +01:00
Francois LE FEVRE 805e0ec7d1 i18n - Updated French Translation 2025-01-12 10:28:19 +01:00
Timothy Jaeryang Baek b1bcca90b5 refac 2025-01-11 20:23:26 -08:00
Timothy Jaeryang Baek 5d3f778b2a fix: openai proxy endpoints 2025-01-11 16:56:25 -08:00
Timothy Jaeryang Baek ccbd98dfab refac: action buttons styling 2025-01-11 13:47:02 -08:00
Timothy Jaeryang Baek 21abe43f3b refac 2025-01-10 16:27:37 -08:00
Timothy Jaeryang Baek dd08b457d8 Merge pull request #8471 from open-webui/main
Update About.svelte
2025-01-10 11:46:59 -08:00
Timothy Jaeryang Baek 4269df041f Update About.svelte 2025-01-10 11:46:35 -08:00
Timothy Jaeryang Baek 042c9fbef3 Merge pull request #8469 from open-webui/main
Update README.md
2025-01-10 11:09:14 -08:00
Timothy Jaeryang Baek 1972f33100 Update README.md 2025-01-10 11:07:45 -08:00
Timothy Jaeryang Baek 6aee001ab0 Merge pull request #8468 from open-webui/main
Update LICENSE
2025-01-10 10:55:37 -08:00
tarmst 49eca68e28 Add toggle to read/write perms on access control 2025-01-10 18:44:50 +00:00
Timothy Jaeryang Baek a76068d69c Update LICENSE 2025-01-10 10:40:39 -08:00
Timothy Jaeryang Baek cd20c578d2 Merge pull request #8449 from hurxxxx/main
fix : incorrect element id
2025-01-10 08:28:47 -08:00
Timothy Jaeryang Baek a078403c34 Merge pull request #8457 from panda44312/patch-2
i18n - zh-cn Updated translation
2025-01-10 08:28:04 -08:00
Timothy Jaeryang Baek 5ccdddff99 Merge pull request #8458 from aindriu80/main
i18n - Updated Irish translations
2025-01-10 08:27:52 -08:00
Aindriú Mac Giolla Eoin 0be703ecb7 Updating Irish translations 2025-01-10 15:11:46 +00:00
Panda 35ca4f565f Update translation.json 2025-01-10 15:48:52 +01:00
Dave York 4d349ac5c9 potential fix for cypress error 2025-01-10 01:27:00 -05:00
Gunwoo Heo a6ca6c1712 fix : incorrect element id 2025-01-10 14:41:39 +09:00
Li, Steel ab6dffffd0 feat: add LDAP_ATTRIBUTE_FOR_MAIL to env-configuration 2025-01-10 08:53:03 +08:00
Timothy Jaeryang Baek 88614ec70a Merge pull request #8432 from juananpe/kb-detachment-from-models
fix: Knowledge Base Detachment from Models
2025-01-09 10:24:57 -08:00
Juanan Pereira f72490093a fix frontend format 2025-01-09 18:56:40 +01:00
Juanan Pereira de735b3c8c fix format 2025-01-09 18:41:18 +01:00
Juanan Pereira 5b616dd34f fix: Knowledge Base Detachment from Models 2025-01-09 16:47:12 +01:00
Timothy Jaeryang Baek 942fd384de refac: chroma 2025-01-08 13:18:14 -08:00
Timothy Jaeryang Baek bd9bc4d7eb Merge pull request #8401 from TiancongLx/dev
i18n: update zh-TW
2025-01-08 11:34:48 -08:00
Timothy Jaeryang Baek 6c4b2b0a8e Merge pull request #8407 from LuisMalhadas/no_line_break_pdf(#8405)
fix: solves the lack of line breaks in chat download as pdf #8405
2025-01-08 11:34:32 -08:00
Timothy Jaeryang Baek 256b9161e6 Merge pull request #8409 from vertago1/main
**docs**: docs/apache.md: Add websocket proxy
2025-01-08 11:34:17 -08:00
Timothy Jaeryang Baek 9516eb3f32 refac: bump chromadb 2025-01-08 11:33:30 -08:00
Allen Webb 76f99d193f docs/apache.md: Add websocket proxy
After 0.5 websocket support is required so update the proxy instructions
to include websockets.

Issue #8074#issuecomment-2562017399
2025-01-08 10:13:00 -06:00
Luis Malhadas 882d8f4ce1 solves the lack of line breaks in chat download as pdf #8405 2025-01-08 16:49:53 +01:00
Tiancong Li 130a771b60 i18n: update zh-TW 2025-01-08 20:17:41 +08:00
Timothy Jaeryang Baek e6db4d017b fix 2025-01-08 00:59:03 -08:00
Timothy Jaeryang Baek 987664f9b5 fix
Co-Authored-By: Izhar Firdaus <480984+kagesenshi@users.noreply.github.com>
2025-01-08 00:57:52 -08:00
Timothy Jaeryang Baek b3c7ecaea1 fix: oauth webhook
Co-Authored-By: Izhar Firdaus <480984+kagesenshi@users.noreply.github.com>
2025-01-08 00:38:00 -08:00
Timothy Jaeryang Baek c79b975ad0 refac: chroma 2025-01-08 00:21:50 -08:00
Timothy Jaeryang Baek 656a887db6 refac 2025-01-08 00:10:38 -08:00
Timothy Jaeryang Baek e63c0aeb1b refac: styling 2025-01-08 00:03:31 -08:00
Timothy Jaeryang Baek 433168c450 fix: add user modal 2025-01-08 00:02:33 -08:00
Timothy Jaeryang Baek 0129f463b4 fix: &lt; rendering issue 2025-01-07 23:59:58 -08:00
Timothy Jaeryang Baek e0054298a4 Merge pull request #8377 from Vojtech-Siler/multiple-error-fixes
fix: Multiple Fixes for Range and Type Errors
2025-01-07 23:53:48 -08:00
Timothy Jaeryang Baek 0e7c3d4eb8 Merge pull request #8379 from qiaozhi199/main
Fix the issue of inaccurate answers after enabling RAG query generation
2025-01-07 23:53:31 -08:00
Timothy Jaeryang Baek 70914d2764 Merge pull request #8376 from steelcg/dev
perf: use ldap3.NONE for parameter get_info in ldap_auth function to accelerate login
2025-01-07 23:50:11 -08:00
Timothy Jaeryang Baek 156579ab3a fix/doc: changelog date 2025-01-07 17:19:46 -08:00
Timothy Jaeryang Baek 81d03540bd Merge pull request #8385 from jk-f5/refac/pgvectorlength
fix: Pgvector vector column size check was failing on initialization …
2025-01-07 14:35:47 -08:00
Jason Kidd b3a52be401 fix: Pgvector vector column size check was failing on initialization of database 2025-01-07 09:15:13 -08:00
zhiguo.qiao 91f22a8a8d Return the top k results with the highest similarity. 2025-01-07 17:41:30 +08:00
Vojtěch Šiler 70a6a0d9e8 Fix errors for RangeErrors, Fix for accessing undefined objects in Chat.svelte 2025-01-07 08:54:42 +01:00
Li, Steel 82ff6c371b use ldap3.NONE for parameter get_info in ldap_auth function to accelerate login 2025-01-07 14:13:18 +08:00
Timothy Jaeryang Baek 0c5bb6df80 Merge pull request #8371 from tarmst/fix-admins-added-to-groups-from-oauth-group-mgmt
fix: Fix admins added to groups from oauth group management
2025-01-06 10:30:08 -08:00
tarmst 8117bf8603 Add admin check 2025-01-06 18:23:42 +00:00
Timothy Jaeryang Baek a0e63b08c3 fix: searchapi engine not showing 2025-01-06 10:10:25 -08:00
Timothy Jaeryang Baek 960683eced fix: torch mps not working
Co-Authored-By: Rich Tong <1782087+richtong@users.noreply.github.com>
2025-01-06 10:08:12 -08:00
Timothy Jaeryang Baek 15a182c9d6 refac: styling 2025-01-05 13:58:29 -08:00
Timothy Jaeryang Baek 61303a216c refac: styling 2025-01-05 13:56:42 -08:00
Timothy Jaeryang Baek 1dfb479d36 Merge pull request #8329 from open-webui/dev
infra: disable codespell
2025-01-05 01:53:03 -08:00
Timothy Jaeryang Baek c517fa906f infra: disable codespell 2025-01-05 01:52:47 -08:00
Timothy Jaeryang Baek 506dc0149c Merge pull request #8246 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.4
2025-01-05 01:38:42 -08:00
Timothy Jaeryang Baek d651cdb1e4 doc: changelog 2025-01-05 01:38:34 -08:00
Timothy Jaeryang Baek 0c40319371 doc: readme 2025-01-05 01:36:27 -08:00
Timothy Jaeryang Baek c74083ace7 doc: wording 2025-01-05 01:34:31 -08:00
Timothy Jaeryang Baek a3ca39f377 refac 2025-01-05 01:33:12 -08:00
Timothy Jaeryang Baek d11e6e40b1 doc: changelog 2025-01-05 01:32:26 -08:00
Timothy Jaeryang Baek 71bbdf25c6 refac 2025-01-05 01:18:30 -08:00
Timothy Jaeryang Baek f4a595db11 chore: format 2025-01-05 01:12:19 -08:00
Timothy Jaeryang Baek c8be0ee1e0 fix: temp chat json export 2025-01-05 01:10:45 -08:00
Timothy Jaeryang Baek 6ed8c3cb70 Update pyproject.toml 2025-01-05 00:56:32 -08:00
Timothy Jaeryang Baek c434aff1a2 refac: styling 2025-01-05 00:48:41 -08:00
Timothy Jaeryang Baek 3886c82ed0 Merge pull request #8286 from srajangarg/sg
feat: functionality to clone shared chats
2025-01-05 00:45:55 -08:00
Timothy Jaeryang Baek fe59b7f39c refac 2025-01-05 00:44:38 -08:00
Timothy Jaeryang Baek a4132322d9 refac 2025-01-05 00:28:36 -08:00
Timothy Jaeryang Baek d477f73c7e enh: torch mps support 2025-01-05 00:27:44 -08:00
Timothy Jaeryang Baek 614379a427 fix: chat drag and drop 2025-01-04 02:42:26 -08:00
Timothy Jaeryang Baek b4d91b5cb4 refac 2025-01-04 02:34:12 -08:00
Timothy Jaeryang Baek f31e2af349 refac: play sound only in last active tab 2025-01-04 02:30:51 -08:00
Timothy Jaeryang Baek afcbf2cafb enh: native notifications 2025-01-04 02:15:57 -08:00
Timothy Jaeryang Baek c0b93791dc refac 2025-01-04 02:05:42 -08:00
Timothy Jaeryang Baek f3a4d61f81 fix: actions 2025-01-03 21:31:24 -08:00
Timothy Jaeryang Baek 99c3194181 fix: API_KEY_ALLOWED_ENDPOINTS 2025-01-03 13:08:21 -08:00
Timothy Jaeryang Baek 0e805e7dc4 Merge pull request #8298 from jk-f5/feat/pg_vector_size
feat: Allow setting the initial vector length on pgvector document_chunk table
2025-01-03 13:03:53 -08:00
Timothy Jaeryang Baek 226408ffe3 Merge pull request #8309 from yarikoptic/enh-codespell
ci: add codespell support (config, workflow to detect/not fix) and make it fix few typos
2025-01-03 13:02:32 -08:00
Yaroslav Halchenko 8f1953e667 [DATALAD RUNCMD] run codespell throughout fixing few left typos automagically
=== Do not change lines below ===
{
 "chain": [],
 "cmd": "codespell -w",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
2025-01-03 15:07:21 -05:00
Yaroslav Halchenko 1a928bc6a4 Add rudimentary codespell config 2025-01-03 15:07:21 -05:00
Yaroslav Halchenko 2e5fbba961 Add github action to codespell main on push and PRs 2025-01-03 15:07:21 -05:00
Jason Kidd 70b74b5217 feat: Allow setting the initial vector length on pgvector document_chunk table 2025-01-03 09:18:59 -08:00
Timothy Jaeryang Baek 996ade9090 refac: notification sound behaviour 2025-01-02 21:39:40 -08:00
Srajan Garg 2444327a47 add functionality to clone shared chats 2025-01-03 00:08:49 -05:00
Timothy Jaeryang Baek 6b3be4bb5b refac: sidebar styling 2025-01-02 20:53:30 -08:00
Timothy Jaeryang Baek b2b56d14cb fix: channel thread 2025-01-02 20:48:50 -08:00
Timothy Jaeryang Baek 3e9c92729d refac: styling 2025-01-02 20:28:01 -08:00
Timothy Jaeryang Baek 0c49553f79 refac: styling 2025-01-02 20:23:07 -08:00
Timothy Jaeryang Baek 66b8166397 refac: styling 2025-01-02 20:19:52 -08:00
Timothy Jaeryang Baek 87ba39df57 fix: moa 2025-01-02 14:32:25 -08:00
Timothy Jaeryang Baek 43964de675 refac: audio input 2025-01-02 02:32:51 -08:00
Timothy Jaeryang Baek fab82b51de fix: generate_queries 2025-01-01 20:13:28 -08:00
Timothy Jaeryang Baek 44716a4cb2 refac: disable ENABLE_REALTIME_CHAT_SAVE by default 2025-01-01 20:12:42 -08:00
Timothy Jaeryang Baek d9fb971196 Merge pull request #8249 from albinvar/main
fix: raise exceptions properly in process_chat_payload
2025-01-01 18:44:42 -08:00
Timothy Jaeryang Baek 51d875a14e Merge pull request #8251 from TiancongLx/dev
i18n: update zh-TW
2025-01-01 18:44:16 -08:00
Tiancong Li 45cc0e6209 i18n: update zh-TW
待翻譯:「Add Reaction」
2025-01-01 21:20:11 +08:00
Albin Varghese c561eb7bbd fix: raise exceptions properly in process_chat_payload 2025-01-01 17:15:06 +05:30
Timothy Jaeryang Baek 00a089b596 doc: readme 2024-12-31 20:37:54 -08:00
Timothy Jaeryang Baek 541fa37c5c Merge pull request #8245 from open-webui/dependabot/pip/backend/dev/peewee-3.17.8
build(deps): bump peewee from 3.17.6 to 3.17.8 in /backend
2024-12-31 20:00:01 -08:00
Timothy Jaeryang Baek 06301aa90d Merge pull request #8243 from open-webui/dependabot/pip/backend/dev/validators-0.34.0
build(deps): bump validators from 0.33.0 to 0.34.0 in /backend
2024-12-31 19:59:45 -08:00
Timothy Jaeryang Baek 55fbc60ad3 Merge pull request #8244 from open-webui/dependabot/pip/backend/dev/flask-3.1.0
build(deps): bump flask from 3.0.3 to 3.1.0 in /backend
2024-12-31 19:59:36 -08:00
Timothy Jaeryang Baek b69941caf4 Merge pull request #8242 from open-webui/dependabot/pip/backend/dev/fpdf2-2.8.2
build(deps): bump fpdf2 from 2.7.9 to 2.8.2 in /backend
2024-12-31 19:59:13 -08:00
Timothy Jaeryang Baek 80600dbe63 Merge pull request #8241 from open-webui/dependabot/pip/backend/dev/pyjwt-crypto--2.10.1
build(deps): bump pyjwt[crypto] from 2.9.0 to 2.10.1 in /backend
2024-12-31 19:58:59 -08:00
Timothy Jaeryang Baek 085f48366b refac 2024-12-31 19:54:11 -08:00
Timothy Jaeryang Baek 60fc24cd39 refac: channel message input 2024-12-31 19:42:49 -08:00
dependabot[bot] 7fb87ddf99 build(deps): bump peewee from 3.17.6 to 3.17.8 in /backend
Bumps [peewee](https://github.com/coleifer/peewee) from 3.17.6 to 3.17.8.
- [Release notes](https://github.com/coleifer/peewee/releases)
- [Changelog](https://github.com/coleifer/peewee/blob/master/CHANGELOG.md)
- [Commits](https://github.com/coleifer/peewee/compare/3.17.6...3.17.8)

---
updated-dependencies:
- dependency-name: peewee
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:15:02 +00:00
dependabot[bot] b41c1128c6 build(deps): bump flask from 3.0.3 to 3.1.0 in /backend
Bumps [flask](https://github.com/pallets/flask) from 3.0.3 to 3.1.0.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/3.0.3...3.1.0)

---
updated-dependencies:
- dependency-name: flask
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:54 +00:00
dependabot[bot] 5bb308a098 build(deps): bump validators from 0.33.0 to 0.34.0 in /backend
Bumps [validators](https://github.com/python-validators/validators) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/python-validators/validators/releases)
- [Changelog](https://github.com/python-validators/validators/blob/master/CHANGES.md)
- [Commits](https://github.com/python-validators/validators/compare/0.33.0...0.34.0)

---
updated-dependencies:
- dependency-name: validators
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:51 +00:00
dependabot[bot] 236410cd95 build(deps): bump fpdf2 from 2.7.9 to 2.8.2 in /backend
Bumps [fpdf2](https://github.com/py-pdf/fpdf2) from 2.7.9 to 2.8.2.
- [Release notes](https://github.com/py-pdf/fpdf2/releases)
- [Changelog](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/fpdf2/compare/2.7.9...2.8.2)

---
updated-dependencies:
- dependency-name: fpdf2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:48 +00:00
dependabot[bot] f1800e2853 build(deps): bump pyjwt[crypto] from 2.9.0 to 2.10.1 in /backend
Bumps [pyjwt[crypto]](https://github.com/jpadilla/pyjwt) from 2.9.0 to 2.10.1.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.9.0...2.10.1)

---
updated-dependencies:
- dependency-name: pyjwt[crypto]
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:45 +00:00
Timothy Jaeryang Baek 4bc9904b3c Merge pull request #8159 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.3
2024-12-31 13:41:03 -08:00
Timothy Jaeryang Baek 995f80002f doc: changelog 2024-12-31 13:40:49 -08:00
Timothy Jaeryang Baek ffdb45cc4b refac 2024-12-31 13:39:03 -08:00
Timothy Jaeryang Baek f5ee5deec8 chore: format 2024-12-31 13:30:59 -08:00
Timothy Jaeryang Baek 02777ffc54 doc: changelog 2024-12-31 13:23:05 -08:00
Timothy Jaeryang Baek a5627d4226 refac 2024-12-31 13:09:17 -08:00
Timothy Jaeryang Baek 435a174316 Merge pull request #8232 from okamototk/improve-jp-trans
i18n - Updated Japanese Translation
2024-12-31 13:04:58 -08:00
Timothy Jaeryang Baek d905202afb refac 2024-12-31 13:04:27 -08:00
Takashi Okamoto 221cc19ac6 i18n - Updated Japanese Translation 2024-12-31 11:58:43 +00:00
Timothy Jaeryang Baek 3d69eaf34f refac: styling 2024-12-31 03:57:43 -08:00
Timothy Jaeryang Baek e7c501677c refac 2024-12-31 03:54:43 -08:00
Timothy Jaeryang Baek 96b1c2024b refac 2024-12-31 03:45:54 -08:00
Timothy Jaeryang Baek f7ecfafe1a refac: styling 2024-12-31 03:25:25 -08:00
Timothy Jaeryang Baek 6ecee8b920 refac: emoji picker optimisation 2024-12-31 03:24:05 -08:00
Timothy Jaeryang Baek cce8f37ada refac 2024-12-31 02:58:15 -08:00
Timothy Jaeryang Baek 60247fe18d refac 2024-12-31 02:51:52 -08:00
Timothy Jaeryang Baek de4e086bd5 refac 2024-12-31 02:48:43 -08:00
Timothy Jaeryang Baek 3da7ff1721 refac 2024-12-31 02:42:46 -08:00
Timothy Jaeryang Baek c8cdc6377b refac 2024-12-31 02:30:03 -08:00
Timothy Jaeryang Baek 85038cef69 refac: styling 2024-12-31 02:27:49 -08:00
Timothy Jaeryang Baek 016b91adda chore: format 2024-12-31 02:26:30 -08:00
Timothy Jaeryang Baek 0846a08795 Merge pull request #8230 from moblangeois/dev
i18n: update fr-FR
2024-12-31 02:24:42 -08:00
Timothy Jaeryang Baek 277eef62c2 Merge pull request #8210 from open-webui/channels
enh: channel threads & reactions
2024-12-31 02:24:10 -08:00
Timothy Jaeryang Baek 9f19933e64 refac: styling 2024-12-31 02:23:26 -08:00
Timothy Jaeryang Baek 904ddcec13 refac: styling 2024-12-31 02:19:22 -08:00
Timothy Jaeryang Baek 77da0a6cc1 refac: styling 2024-12-31 02:17:39 -08:00
Timothy Jaeryang Baek a754a4388a feat: channel threads 2024-12-31 02:16:07 -08:00
Morgan Blangeois 93f262c6a2 i18n: update fr-FR 2024-12-31 11:09:31 +01:00
Timothy Jaeryang Baek 16a48ef4eb fix 2024-12-31 02:06:55 -08:00
Timothy Jaeryang Baek 841f74894e refac 2024-12-31 02:06:19 -08:00
Timothy Jaeryang Baek 9c337552e6 enh: reply count 2024-12-31 02:05:11 -08:00
Timothy Jaeryang Baek 584e9e6da5 refac: threads 2024-12-31 00:51:43 -08:00
Timothy Jaeryang Baek a3dfa90668 refac: optimisation 2024-12-31 00:11:46 -08:00
Timothy Jaeryang Baek 00e8849445 refac 2024-12-30 23:55:19 -08:00
Timothy Jaeryang Baek 2840ff405b refac 2024-12-30 23:48:55 -08:00
Timothy Jaeryang Baek 9d39404e6c refac: styling 2024-12-30 23:16:50 -08:00
Timothy Jaeryang Baek 371201d820 refac 2024-12-30 23:12:50 -08:00
Timothy Jaeryang Baek f93c2e4a8d feat: reactions 2024-12-30 23:06:34 -08:00
Timothy Jaeryang Baek 4b0fa112bb Merge pull request #8222 from open-webui/dev
dev
2024-12-30 17:55:13 -08:00
Timothy Jaeryang Baek fd0170c179 revert 2024-12-30 16:55:29 -08:00
Timothy Jaeryang Baek 9b56b64cfa Merge pull request #8212 from ashm-dev/main
feat: Small optimization
2024-12-30 16:00:18 -08:00
Timothy Jaeryang Baek 887627fa34 Merge pull request #8217 from gabriel-ecegi/batch_fix
fix: Add missing request parameter to knowledge and retrieval routes
2024-12-30 15:58:10 -08:00
Timothy Jaeryang Baek 46bcf98ef2 fix: usage stats 2024-12-30 15:52:07 -08:00
Timothy Jaeryang Baek 947f5600d6 refac 2024-12-30 15:39:35 -08:00
Timothy Jaeryang Baek 79ce6e0a3f refac 2024-12-30 11:29:18 -08:00
Gabriel Ecegi 46e57706c1 refac: formatting 2024-12-30 17:45:43 +01:00
Gabriel Ecegi ba2964cb01 fix: missing parameter 2024-12-30 17:36:34 +01:00
shamil a0aee4ff28 feat: Small optimization 2024-12-30 13:45:20 +03:00
Timothy Jaeryang Baek 45289b0fa8 refac 2024-12-30 02:21:45 -08:00
Timothy Jaeryang Baek eece28ccc6 feat: emoji picker 2024-12-30 02:20:09 -08:00
Timothy Jaeryang Baek c216d89520 enh: threads & reactions 2024-12-29 23:16:18 -08:00
Timothy Jaeryang Baek d94134ad2d refac 2024-12-29 22:11:20 -08:00
Timothy Jaeryang Baek 952822ad5d refac 2024-12-29 21:02:11 -08:00
Timothy Jaeryang Baek 79d7e67e51 fix: ollama options 2024-12-29 16:30:36 -08:00
Timothy Jaeryang Baek 6de1923151 Merge pull request #8192 from TiancongLx/dev
i18n: update zh-TW
2024-12-29 15:36:19 -08:00
Tiancong Li abdbd6a67e i18n: update zh-TW 2024-12-30 03:13:20 +08:00
Timothy Jaeryang Baek a14dee87e8 Merge pull request #8184 from vishwamartur/fix-offline-docker
Fix offline docker container startup issue
2024-12-28 22:26:38 -08:00
Vishwanath Martur 00e6ffe83c Fix offline docker container startup issue
Related to #7207

Modify the code to allow the docker container to start in an offline environment for versions >= 0.4.0.

* **backend/open_webui/retrieval/utils.py**
  - Import `OFFLINE_MODE` from `open_webui.env`.
  - Set `local_files_only` to `True` when `OFFLINE_MODE` is enabled in `snapshot_kwargs`.

* **backend/open_webui/env.py**
  - Add logic to set `HF_HUB_OFFLINE` environment variable to `1` when `OFFLINE_MODE` is enabled.

* **README.md**
  - Document setting `HF_HUB_OFFLINE` environment variable to `1` for offline environments.
2024-12-29 11:53:09 +05:30
Timothy Jaeryang Baek 36d927e1ba refac 2024-12-28 19:31:03 -08:00
Timothy Jaeryang Baek 091e9e2f40 refac 2024-12-28 18:08:30 -08:00
Timothy Jaeryang Baek 54daf3b765 fix: outlet filter hook 2024-12-28 18:05:07 -08:00
Timothy Jaeryang Baek 76decdba2a fix: delete file 2024-12-28 17:40:00 -08:00
Timothy Jaeryang Baek e6e8978f78 refac 2024-12-28 16:42:43 -08:00
Timothy Jaeryang Baek fcaeb2f539 Merge pull request #8179 from cvaz1306/add_reset_button_mermaidjs_renderer
i18n: Add reset panzoom button to SVGPanZoom and cleanup
2024-12-28 16:27:44 -08:00
cvaz1306 f7d1833f41 Fixing formatting 2024-12-28 15:50:23 -08:00
cvaz1306 95598e5435 Merge branch 'dev' into add_reset_button_mermaidjs_renderer 2024-12-28 15:46:23 -08:00
cvaz1306 8a7e8b57a8 Add reset panzoom button to SVGPanZoom and cleanup 2024-12-28 15:27:47 -08:00
Timothy Jaeryang Baek 5a720a4a31 refac 2024-12-27 22:46:50 -08:00
Timothy Jaeryang Baek d55884b50e enh: ENABLE_REALTIME_CHAT_SAVE 2024-12-27 22:36:14 -08:00
Timothy Jaeryang Baek c8fb11db59 Merge pull request #8158 from cvaz1306/add_reset_button_mermaidjs_renderer
feat: Add reset button mermaidjs renderer
2024-12-27 17:55:00 -08:00
cvaz1306 7b3a86475d Add reset panzoom button to SVGPanZoom and cleanup 2024-12-27 17:06:15 -08:00
Timothy Jaeryang Baek 59ed53ec1e Merge pull request #8145 from bernadinm/fix/modified_update_ollama_models.sh
fix: Add execute permissions to update_ollama_models.sh
2024-12-27 16:24:32 -08:00
Timothy Jaeryang Baek 7f8b6ebbd5 refac: styling 2024-12-27 12:24:58 -08:00
Miguel Bernadin 3f0dd80898 fix: chmod +x update_ollama_models.sh 2024-12-27 08:29:54 -08:00
Timothy Jaeryang Baek 2299f48430 Merge pull request #8131 from open-webui/dev
fix: api key restrictions
2024-12-27 00:32:37 -08:00
Timothy Jaeryang Baek 99386bf680 fix: api key restrictions 2024-12-27 00:32:25 -08:00
Timothy Jaeryang Baek 4e4e2792b1 Merge pull request #8130 from open-webui/dev
refac
2024-12-27 00:06:41 -08:00
Timothy Jaeryang Baek d99e3d49e1 refac 2024-12-27 00:05:32 -08:00
Timothy Jaeryang Baek e42cbf07f5 Merge pull request #8122 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.2
2024-12-26 23:51:49 -08:00
Timothy Jaeryang Baek 016862a6b8 doc: changelog 2024-12-26 23:51:40 -08:00
Timothy Jaeryang Baek cd9dae0f3a refac 2024-12-26 23:51:30 -08:00
Timothy Jaeryang Baek b7baf4ddef doc: changelog 2024-12-26 23:46:07 -08:00
Timothy Jaeryang Baek 1584015d3b chore: format 2024-12-26 23:35:56 -08:00
Timothy Jaeryang Baek e4b4a78e52 refac 2024-12-26 23:35:01 -08:00
Timothy Jaeryang Baek 50534a0dcf enh: user status indicator 2024-12-26 23:29:33 -08:00
Timothy Jaeryang Baek c53ace3c98 refac 2024-12-26 22:03:39 -08:00
Timothy Jaeryang Baek 7fa60dd63e refac 2024-12-26 21:55:21 -08:00
Timothy Jaeryang Baek 6ff6d57507 enh: typing indicator 2024-12-26 21:51:09 -08:00
Timothy Jaeryang Baek 4f93ecf519 refac 2024-12-26 20:58:46 -08:00
Timothy Jaeryang Baek 1e974439d9 enh: configurable api key endpoint restrictions 2024-12-26 20:57:51 -08:00
Timothy Jaeryang Baek 611955bc91 refac 2024-12-26 20:38:27 -08:00
Timothy Jaeryang Baek e3937ada38 fix: ollama usage 2024-12-26 20:35:14 -08:00
Timothy Jaeryang Baek c5b8466c0e fix: playground 2024-12-26 19:12:08 -08:00
Timothy Jaeryang Baek e25b082162 fix: pipelines outlet 2024-12-26 19:10:28 -08:00
Timothy Jaeryang Baek 247d7896fc Merge pull request #8093 from aleixdorca/dev
i18n: Updated Catalan translation.json
2024-12-26 13:34:30 -08:00
Timothy Jaeryang Baek a0ad681c77 Merge pull request #8101 from panda44312/dev
i18n - Updated Simplified Chinese Translation
2024-12-26 13:34:21 -08:00
Timothy Jaeryang Baek 1b7ee5dd2b Merge pull request #8115 from taylorwilsdon/fix_drive_integration
Fix drive integration
2024-12-26 13:34:09 -08:00
Timothy Jaeryang Baek 8b6d03e430 fix: elevenlabs audio 2024-12-26 12:54:31 -08:00
Taylor Wilsdon efc4ad9f65 Update Documents.svelte 2024-12-26 14:48:38 -05:00
Taylor Wilsdon 3cfb6507c5 Update .gitignore 2024-12-26 14:47:41 -05:00
Taylor Wilsdon 24a314876b formatting 2024-12-26 14:46:41 -05:00
Taylor Wilsdon 190911206b Remove logging 2024-12-26 14:46:06 -05:00
Taylor Wilsdon ea11521961 Remove unnecessary logging 2024-12-26 14:44:36 -05:00
Taylor Wilsdon 909c94e983 Fix Google Drive integration 2024-12-26 14:41:58 -05:00
Taylor Wilsdon (aider) 1c7ff7b1dc fix: Initialize enableGoogleDriveIntegration with default value 2024-12-26 12:22:58 -05:00
Taylor Wilsdon (aider) c7cec2131c debug: Add detailed logging for Google Drive Integration state changes 2024-12-26 12:21:38 -05:00
Taylor Wilsdon (aider) 6f3e7ed0e9 debug: Add console logs for Google Drive Integration state changes 2024-12-26 12:19:53 -05:00
Panda d4ccd25760 Update translation.json 2024-12-26 11:51:44 +01:00
Timothy Jaeryang Baek dd933e406f Merge pull request #8095 from open-webui/dev
fix: automatic1111 image generation
2024-12-25 23:35:10 -08:00
Timothy Jaeryang Baek 3c74ce3653 fix: automatic1111 image generation 2024-12-25 23:34:54 -08:00
Timothy Jaeryang Baek de2bda3e2e Merge pull request #8094 from open-webui/dev
refac: title generation logic
2024-12-25 23:52:21 -07:00
Timothy Jaeryang Baek 4b3e1bb747 refac: title generation logic 2024-12-25 22:51:08 -08:00
Aleix Dorca bb30a83520 Update catalan translation.json 2024-12-26 07:49:49 +01:00
Timothy Jaeryang Baek 2bdf99b398 Merge pull request #8071 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.1
2024-12-25 23:31:01 -07:00
Timothy Jaeryang Baek 96a06effe2 doc: wording 2024-12-25 22:30:52 -08:00
Timothy Jaeryang Baek df4a8e167d doc: changelog 2024-12-25 22:28:20 -08:00
Timothy Jaeryang Baek c16896e96d refac 2024-12-25 22:23:59 -08:00
Timothy Jaeryang Baek da7fa09053 fix: non-stream chat completion 2024-12-25 22:21:44 -08:00
Timothy Jaeryang Baek 23bf71022e fix: title generation issue 2024-12-25 16:46:49 -07:00
Timothy Jaeryang Baek 798886105e enh: option to toggle notification sound 2024-12-25 13:49:24 -07:00
Timothy Jaeryang Baek 645313e321 Merge pull request #8062 from OriginalSimon/main
18n: Update Ukrainian translation
2024-12-25 12:44:32 -08:00
Timothy Jaeryang Baek 0915352cb0 Merge pull request #8068 from open-webui/dev
fix: get users
2024-12-25 12:39:27 -08:00
Timothy Jaeryang Baek 70a108e54f fix: get users 2024-12-25 13:39:04 -07:00
Timothy Jaeryang Baek a8289745da Merge pull request #8066 from open-webui/dev
fix: get_automatic1111_api_auth
2024-12-25 12:26:59 -08:00
Timothy Jaeryang Baek d3ba77837a fix: get_automatic1111_api_auth 2024-12-25 13:26:13 -07:00
Timothy Jaeryang Baek 3161a5d1f7 Merge pull request #8063 from open-webui/dev
fix: pipelines not loading
2024-12-25 12:08:27 -08:00
Timothy Jaeryang Baek 3a35f9ae7c fix: pipelines not loading 2024-12-25 13:07:55 -07:00
Simon 12cf2b0104 Update translation.json 2024-12-25 20:34:01 +01:00
MadsLang 6f1065b56a Merge branch 'dev' of github.com:open-webui/open-webui 2024-12-16 10:00:24 +01:00
MadsLang 332188535e add option to add database schema as env var 2024-12-10 15:18:26 +01:00
4077 changed files with 21844 additions and 4847 deletions
+25
View File
@@ -0,0 +1,25 @@
# Codespell configuration is within pyproject.toml
---
name: Codespell
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2
@@ -52,6 +52,8 @@ jobs:
- name: Cypress run
uses: cypress-io/github-action@v6
env:
LIBGL_ALWAYS_SOFTWARE: 1
with:
browser: chrome
wait-on: 'http://localhost:3000'
+102
View File
@@ -5,6 +5,108 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.5] - 2025-01-22
### Added
- **🤔 Native 'Think' Tag Support**: Introduced the new 'think' tag support that visually displays how long the model is thinking, omitting the reasoning content itself until the next turn. Ideal for creating a more streamlined and focused interaction experience.
- **🖼️ Toggle Image Generation On/Off**: In the chat input menu, you can now easily toggle image generation before initiating chats, providing greater control and flexibility to suit your needs.
- **🔒 Chat Controls Permissions**: Admins can now disable chat controls access for users, offering tighter management and customization over user interactions.
- **🔍 Web Search & Image Generation Permissions**: Easily disable web search and image generation for specific users, improving workflow governance and security for certain environments.
- **🗂️ S3 and GCS Storage Provider Support**: Scaled deployments now benefit from expanded storage options with Amazon S3 and Google Cloud Storage seamlessly integrated as providers.
- **🎨 Enhanced Model Management**: Reintroduced the ability to download and delete models directly in the admin models settings page to minimize user confusion and aid efficient model management.
- **🔗 Improved Connection Handling**: Enhanced backend to smoothly handle multiple identical base URLs, allowing more flexible multi-instance configurations with fewer hiccups.
- **✨ General UI/UX Refinements**: Numerous tweaks across the WebUI make navigation and usability even more user-friendly and intuitive.
- **🌍 Translation Enhancements**: Various translation updates ensure smoother and more polished interactions for international users.
### Fixed
- **⚡ MPS Functionality for Mac Users**: Fixed MPS support, ensuring smooth performance and compatibility for Mac users leveraging MPS.
- **📡 Ollama Connection Management**: Resolved the issue where deleting all Ollama connections prevented adding new ones.
### Changed
- **⚙️ General Stability Refac**: Backend refactoring delivers a more stable, robust platform.
- **🖥️ Desktop App Preparations**: Ongoing work to support the upcoming Open WebUI desktop app. Follow our progress and updates here: https://github.com/open-webui/desktop
## [0.5.4] - 2025-01-05
### Added
- **🔄 Clone Shared Chats**: Effortlessly clone shared chats to save time and streamline collaboration, perfect for reusing insightful discussions or custom setups.
- **📣 Native Notifications for Channel Messages**: Stay informed with integrated desktop notifications for channel messages, ensuring you never miss important updates while multitasking.
- **🔥 Torch MPS Support**: MPS support for Mac users when Open WebUI is installed directly, offering better performance and compatibility for AI workloads.
- **🌍 Enhanced Translations**: Small improvements to various translations, ensuring a smoother global user experience.
### Fixed
- **🖼️ Image-Only Messages in Channels**: You can now send images without accompanying text or content in channels.
- **❌ Proper Exception Handling**: Enhanced error feedback by ensuring exceptions are raised clearly, reducing confusion and promoting smoother debugging.
- **🔍 RAG Query Generation Restored**: Fixed query generation issues for Retrieval-Augmented Generation, improving retrieval accuracy and ensuring seamless functionality.
- **📩 MOA Response Functionality Fixed**: Addressed an error with the MOA response generation feature.
- **💬 Channel Thread Loading with 50+ Messages**: Resolved an issue where channel threads stalled when exceeding 50 messages, ensuring smooth navigation in active discussions.
- **🔑 API Endpoint Restrictions Resolution**: Fixed a critical bug where the 'API_KEY_ALLOWED_ENDPOINTS' setting was not functioning as intended, ensuring API access is limited to specified endpoints for enhanced security.
- **🛠️ Action Functions Restored**: Corrected an issue preventing action functions from working, restoring their utility for customized automations and workflows.
- **📂 Temporary Chat JSON Export Fix**: Resolved a bug blocking temporary chats from being exported in JSON format, ensuring seamless data portability.
### Changed
- **🎛️ Sidebar UI Tweaks**: Chat folders, including pinned folders, now display below the Chats section for better organization; the "New Folder" button has been relocated to the Chats section for a more intuitive workflow.
- **🏗️ Real-Time Save Disabled by Default**: The 'ENABLE_REALTIME_CHAT_SAVE' setting is now off by default, boosting response speed for users who prioritize performance in high-paced workflows or less critical scenarios.
- **🎤 Audio Input Echo Cancellation**: Audio input now features echo cancellation enabled by default, reducing audio feedback for improved clarity during conversations or voice-based interactions.
- **🔧 General Reliability Improvements**: Numerous under-the-hood enhancements have been made to improve platform stability, boost overall performance, and ensure a more seamless, dependable experience across workflows.
## [0.5.3] - 2024-12-31
### Added
- **💬 Channel Reactions with Built-In Emoji Picker**: Easily express yourself in channel threads and messages with reactions, featuring an intuitive built-in emoji picker for seamless selection.
- **🧵 Threads for Channels**: Organize discussions within channels by creating threads, improving clarity and fostering focused conversations.
- **🔄 Reset Button for SVG Pan/Zoom**: Added a handy reset button to SVG Pan/Zoom, allowing users to quickly return diagrams or visuals to their default state without hassle.
- **⚡ Realtime Chat Save Environment Variable**: Introduced the ENABLE_REALTIME_CHAT_SAVE environment variable. Choose between faster responses by disabling realtime chat saving or ensuring chunk-by-chunk data persistency for critical operations.
- **🌍 Translation Enhancements**: Updated and refined translations across multiple languages, providing a smoother experience for international users.
- **📚 Improved Documentation**: Expanded documentation on functions, including clearer guidance on function plugins and detailed instructions for migrating to v0.5. This ensures users can adapt and harness new updates more effectively. (https://docs.openwebui.com/features/plugin/)
### Fixed
- **🛠️ Ollama Parameters Respected**: Resolved an issue where input parameters for Ollama were being ignored, ensuring precise and consistent model behavior.
- **🔧 Function Plugin Outlet Hook Reliability**: Fixed a bug causing issues with 'event_emitter' and outlet hooks in filter function plugins, guaranteeing smoother operation within custom extensions.
- **🖋️ Weird Custom Status Descriptions**: Adjusted the formatting and functionality for custom user statuses, ensuring they display correctly and intuitively.
- **🔗 Restored API Functionality**: Fixed a critical issue where APIs were not operational for certain configurations, ensuring uninterrupted access.
- **⏳ Custom Pipe Function Completion**: Resolved an issue where chats using specific custom pipe function plugins werent finishing properly, restoring consistent chat workflows.
- **✅ General Stability Enhancements**: Implemented various under-the-hood improvements to boost overall reliability, ensuring smoother and more consistent performance across the WebUI.
## [0.5.2] - 2024-12-26
### Added
- **🖊️ Typing Indicators in Channels**: Know exactly whos typing in real-time within your channels, enhancing collaboration and keeping everyone engaged.
- **👤 User Status Indicators**: Quickly view a users status by clicking their profile image in channels for better coordination and availability insights.
- **🔒 Configurable API Key Authentication Restrictions**: Flexibly configure endpoint restrictions for API key authentication, now off by default for a smoother setup in trusted environments.
### Fixed
- **🔧 Playground Functionality Restored**: Resolved a critical issue where the playground wasnt working, ensuring seamless experimentation and troubleshooting workflows.
- **📊 Corrected Ollama Usage Statistics**: Fixed a calculation error in Ollamas usage statistics, providing more accurate tracking and insights for better resource management.
- **🔗 Pipelines Outlet Hook Registration**: Addressed an issue where outlet hooks for pipelines werent registered, restoring functionality and consistency in pipeline workflows.
- **🎨 Image Generation Error**: Resolved a persistent issue causing errors with 'get_automatic1111_api_auth()' to ensure smooth image generation workflows.
- **🎙️ Text-to-Speech Error**: Fixed the missing argument in Eleven Labs 'get_available_voices()', restoring full text-to-speech capabilities for uninterrupted voice interactions.
- **🖋️ Title Generation Issue**: Fixed a bug where title generation was not working in certain cases, ensuring consistent and reliable chat organization.
## [0.5.1] - 2024-12-25
### Added
- **🔕 Notification Sound Toggle**: Added a new setting under Settings > Interface to disable notification sounds, giving you greater control over your workspace environment and focus.
### Fixed
- **🔄 Non-Streaming Response Visibility**: Resolved an issue where non-streaming responses were not displayed, ensuring all responses are now reliably shown in your conversations.
- **🖋️ Title Generation with OpenAI APIs**: Fixed a bug preventing title generation when using OpenAI APIs, restoring the ability to automatically generate chat titles for smoother organization.
- **👥 Admin Panel User List**: Addressed the issue where only 50 users were visible in the admin panel. You can now manage and view all users without restrictions.
- **🖼️ Image Generation Error**: Fixed the issue causing 'get_automatic1111_api_auth()' errors in image generation, ensuring seamless creative workflows.
- **⚙️ Pipeline Settings Loading Issue**: Resolved a problem where pipeline settings were stuck at the loading screen, restoring full configurability in the admin panel.
## [0.5.0] - 2024-12-25
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
## Our Pledge
As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct.
+23 -17
View File
@@ -1,21 +1,27 @@
MIT License
Copyright (c) 2023-2025 Timothy Jaeryang Baek
All rights reserved.
Copyright (c) 2023 Timothy Jaeryang Baek
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+12 -2
View File
@@ -11,7 +11,9 @@
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
Open WebUI is an [extensible](https://github.com/open-webui/pipelines), 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/).
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
![Open WebUI Demo](./demo.gif)
@@ -185,13 +187,21 @@ If you want to try out the latest bleeding-edge features and are okay with occas
docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --add-host=host.docker.internal:host-gateway --restart always ghcr.io/open-webui/open-webui:dev
```
### Offline Mode
If you are running Open WebUI in an offline environment, you can set the `HF_HUB_OFFLINE` environment variable to `1` to prevent attempts to download models from the internet.
```bash
export HF_HUB_OFFLINE=1
```
## What's Next? 🌟
Discover upcoming features on our roadmap in the [Open WebUI Documentation](https://docs.openwebui.com/roadmap/).
## License 📜
This project is licensed under the [MIT License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
## Support 💬
+19
View File
@@ -5,12 +5,31 @@ from pathlib import Path
import typer
import uvicorn
from typing import Optional
from typing_extensions import Annotated
app = typer.Typer()
KEY_FILE = Path.cwd() / ".webui_secret_key"
def version_callback(value: bool):
if value:
from open_webui.env import VERSION
typer.echo(f"Open WebUI version: {VERSION}")
raise typer.Exit()
@app.command()
def main(
version: Annotated[
Optional[bool], typer.Option("--version", callback=version_callback)
] = None,
):
pass
@app.command()
def serve(
host: str = "0.0.0.0",
+199 -45
View File
@@ -9,22 +9,22 @@ from urllib.parse import urlparse
import chromadb
import requests
import yaml
from open_webui.internal.db import Base, get_db
from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func
from open_webui.env import (
OPEN_WEBUI_DIR,
DATA_DIR,
DATABASE_URL,
ENV,
FRONTEND_BUILD_DIR,
OFFLINE_MODE,
OPEN_WEBUI_DIR,
WEBUI_AUTH,
WEBUI_FAVICON_URL,
WEBUI_NAME,
log,
DATABASE_URL,
OFFLINE_MODE,
)
from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func
from open_webui.internal.db import Base, get_db
class EndpointFilter(logging.Filter):
@@ -272,6 +272,18 @@ ENABLE_API_KEY = PersistentConfig(
os.environ.get("ENABLE_API_KEY", "True").lower() == "true",
)
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = PersistentConfig(
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS",
"auth.api_key.endpoint_restrictions",
os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False").lower() == "true",
)
API_KEY_ALLOWED_ENDPOINTS = PersistentConfig(
"API_KEY_ALLOWED_ENDPOINTS",
"auth.api_key.allowed_endpoints",
os.environ.get("API_KEY_ALLOWED_ENDPOINTS", ""),
)
JWT_EXPIRES_IN = PersistentConfig(
"JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1")
@@ -307,17 +319,6 @@ GOOGLE_CLIENT_SECRET = PersistentConfig(
os.environ.get("GOOGLE_CLIENT_SECRET", ""),
)
GOOGLE_DRIVE_CLIENT_ID = PersistentConfig(
"GOOGLE_DRIVE_CLIENT_ID",
"google_drive.client_id",
os.environ.get("GOOGLE_DRIVE_CLIENT_ID", ""),
)
GOOGLE_DRIVE_API_KEY = PersistentConfig(
"GOOGLE_DRIVE_API_KEY",
"google_drive.api_key",
os.environ.get("GOOGLE_DRIVE_API_KEY", ""),
)
GOOGLE_OAUTH_SCOPE = PersistentConfig(
"GOOGLE_OAUTH_SCOPE",
@@ -361,6 +362,30 @@ MICROSOFT_REDIRECT_URI = PersistentConfig(
os.environ.get("MICROSOFT_REDIRECT_URI", ""),
)
GITHUB_CLIENT_ID = PersistentConfig(
"GITHUB_CLIENT_ID",
"oauth.github.client_id",
os.environ.get("GITHUB_CLIENT_ID", ""),
)
GITHUB_CLIENT_SECRET = PersistentConfig(
"GITHUB_CLIENT_SECRET",
"oauth.github.client_secret",
os.environ.get("GITHUB_CLIENT_SECRET", ""),
)
GITHUB_CLIENT_SCOPE = PersistentConfig(
"GITHUB_CLIENT_SCOPE",
"oauth.github.scope",
os.environ.get("GITHUB_CLIENT_SCOPE", "user:email"),
)
GITHUB_CLIENT_REDIRECT_URI = PersistentConfig(
"GITHUB_CLIENT_REDIRECT_URI",
"oauth.github.redirect_uri",
os.environ.get("GITHUB_CLIENT_REDIRECT_URI", ""),
)
OAUTH_CLIENT_ID = PersistentConfig(
"OAUTH_CLIENT_ID",
"oauth.oidc.client_id",
@@ -467,12 +492,20 @@ OAUTH_ALLOWED_DOMAINS = PersistentConfig(
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
def google_oauth_register(client):
client.register(
name="google",
client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
redirect_uri=GOOGLE_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["google"] = {
"client_id": GOOGLE_CLIENT_ID.value,
"client_secret": GOOGLE_CLIENT_SECRET.value,
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"scope": GOOGLE_OAUTH_SCOPE.value,
"redirect_uri": GOOGLE_REDIRECT_URI.value,
"register": google_oauth_register,
}
if (
@@ -480,12 +513,44 @@ def load_oauth_providers():
and MICROSOFT_CLIENT_SECRET.value
and MICROSOFT_CLIENT_TENANT_ID.value
):
def microsoft_oauth_register(client):
client.register(
name="microsoft",
client_id=MICROSOFT_CLIENT_ID.value,
client_secret=MICROSOFT_CLIENT_SECRET.value,
server_metadata_url=f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
client_kwargs={
"scope": MICROSOFT_OAUTH_SCOPE.value,
},
redirect_uri=MICROSOFT_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["microsoft"] = {
"client_id": MICROSOFT_CLIENT_ID.value,
"client_secret": MICROSOFT_CLIENT_SECRET.value,
"server_metadata_url": f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
"scope": MICROSOFT_OAUTH_SCOPE.value,
"redirect_uri": MICROSOFT_REDIRECT_URI.value,
"picture_url": "https://graph.microsoft.com/v1.0/me/photo/$value",
"register": microsoft_oauth_register,
}
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
def github_oauth_register(client):
client.register(
name="github",
client_id=GITHUB_CLIENT_ID.value,
client_secret=GITHUB_CLIENT_SECRET.value,
access_token_url="https://github.com/login/oauth/access_token",
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["github"] = {
"redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value,
"register": github_oauth_register,
"sub_claim": "id",
}
if (
@@ -493,13 +558,23 @@ def load_oauth_providers():
and OAUTH_CLIENT_SECRET.value
and OPENID_PROVIDER_URL.value
):
def oidc_oauth_register(client):
client.register(
name="oidc",
client_id=OAUTH_CLIENT_ID.value,
client_secret=OAUTH_CLIENT_SECRET.value,
server_metadata_url=OPENID_PROVIDER_URL.value,
client_kwargs={
"scope": OAUTH_SCOPES.value,
},
redirect_uri=OPENID_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["oidc"] = {
"client_id": OAUTH_CLIENT_ID.value,
"client_secret": OAUTH_CLIENT_SECRET.value,
"server_metadata_url": OPENID_PROVIDER_URL.value,
"scope": OAUTH_SCOPES.value,
"name": OAUTH_PROVIDER_NAME.value,
"redirect_uri": OPENID_REDIRECT_URI.value,
"register": oidc_oauth_register,
}
@@ -579,7 +654,7 @@ if CUSTOM_NAME:
# STORAGE PROVIDER
####################################
STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "") # defaults to local, s3
STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "local") # defaults to local, s3
S3_ACCESS_KEY_ID = os.environ.get("S3_ACCESS_KEY_ID", None)
S3_SECRET_ACCESS_KEY = os.environ.get("S3_SECRET_ACCESS_KEY", None)
@@ -587,6 +662,11 @@ S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None)
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None)
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None)
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None)
GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get(
"GOOGLE_APPLICATION_CREDENTIALS_JSON", None
)
####################################
# File Upload DIR
####################################
@@ -818,6 +898,10 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
@@ -834,23 +918,39 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_WEB_SEARCH = (
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_IMAGE_GENERATION = (
os.environ.get("USER_PERMISSIONS_FEATURES_IMAGE_GENERATION", "True").lower()
== "true"
)
DEFAULT_USER_PERMISSIONS = {
"workspace": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
},
"features": {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
},
}
USER_PERMISSIONS = PersistentConfig(
"USER_PERMISSIONS",
"user.permissions",
{
"workspace": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"chat": {
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
},
},
DEFAULT_USER_PERMISSIONS,
)
ENABLE_CHANNELS = PersistentConfig(
@@ -1033,6 +1133,32 @@ JSON format: { "tags": ["tag1", "tag2", "tag3"] }
{{MESSAGES:END:6}}
</chat_history>"""
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE",
"task.image.prompt_template",
os.environ.get("IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", ""),
)
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = """### Task:
Generate a detailed prompt for am image generation task based on the given language and context. Describe the image as if you were explaining it to someone who cannot see it. Include relevant details, colors, shapes, and any other important elements.
### Guidelines:
- Be descriptive and detailed, focusing on the most important aspects of the image.
- Avoid making assumptions or adding information not present in the image.
- Use the chat's primary language; default to English if multilingual.
- If the image is too complex, focus on the most prominent elements.
### Output:
Strictly return in JSON format:
{
"prompt": "Your detailed description here."
}
### Chat History:
<chat_history>
{{MESSAGES:END:6}}
</chat_history>"""
ENABLE_TAGS_GENERATION = PersistentConfig(
"ENABLE_TAGS_GENERATION",
"task.tags.enable",
@@ -1192,6 +1318,7 @@ CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
# Milvus
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
# Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None)
@@ -1210,6 +1337,9 @@ if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"):
raise ValueError(
"Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database."
)
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
####################################
# Information Retrieval (RAG)
@@ -1223,6 +1353,17 @@ ENABLE_GOOGLE_DRIVE_INTEGRATION = PersistentConfig(
os.getenv("ENABLE_GOOGLE_DRIVE_INTEGRATION", "False").lower() == "true",
)
GOOGLE_DRIVE_CLIENT_ID = PersistentConfig(
"GOOGLE_DRIVE_CLIENT_ID",
"google_drive.client_id",
os.environ.get("GOOGLE_DRIVE_CLIENT_ID", ""),
)
GOOGLE_DRIVE_API_KEY = PersistentConfig(
"GOOGLE_DRIVE_API_KEY",
"google_drive.api_key",
os.environ.get("GOOGLE_DRIVE_API_KEY", ""),
)
# RAG Content Extraction
CONTENT_EXTRACTION_ENGINE = PersistentConfig(
@@ -1587,6 +1728,13 @@ ENABLE_IMAGE_GENERATION = PersistentConfig(
"image_generation.enable",
os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true",
)
ENABLE_IMAGE_PROMPT_GENERATION = PersistentConfig(
"ENABLE_IMAGE_PROMPT_GENERATION",
"image_generation.prompt.enable",
os.environ.get("ENABLE_IMAGE_PROMPT_GENERATION", "true").lower() == "true",
)
AUTOMATIC1111_BASE_URL = PersistentConfig(
"AUTOMATIC1111_BASE_URL",
"image_generation.automatic1111.base_url",
@@ -1916,6 +2064,12 @@ LDAP_SERVER_PORT = PersistentConfig(
int(os.environ.get("LDAP_SERVER_PORT", "389")),
)
LDAP_ATTRIBUTE_FOR_MAIL = PersistentConfig(
"LDAP_ATTRIBUTE_FOR_MAIL",
"ldap.server.attribute_for_mail",
os.environ.get("LDAP_ATTRIBUTE_FOR_MAIL", "mail"),
)
LDAP_ATTRIBUTE_FOR_USERNAME = PersistentConfig(
"LDAP_ATTRIBUTE_FOR_USERNAME",
"ldap.server.attribute_for_username",
+1
View File
@@ -113,6 +113,7 @@ class TASKS(str, Enum):
TAGS_GENERATION = "tags_generation"
EMOJI_GENERATION = "emoji_generation"
QUERY_GENERATION = "query_generation"
IMAGE_PROMPT_GENERATION = "image_prompt_generation"
AUTOCOMPLETE_GENERATION = "autocomplete_generation"
FUNCTION_CALLING = "function_calling"
MOA_RESPONSE_GENERATION = "moa_response_generation"
+17
View File
@@ -53,6 +53,13 @@ if USE_CUDA.lower() == "true":
else:
DEVICE_TYPE = "cpu"
try:
import torch
if torch.backends.mps.is_available() and torch.backends.mps.is_built():
DEVICE_TYPE = "mps"
except Exception:
pass
####################################
# LOGGING
@@ -267,6 +274,8 @@ DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
if "postgres://" in DATABASE_URL:
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
if DATABASE_POOL_SIZE == "":
@@ -311,6 +320,11 @@ RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
####################################
# REDIS
####################################
@@ -392,3 +406,6 @@ else:
####################################
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1"
+4 -2
View File
@@ -7,6 +7,7 @@ from open_webui.internal.wrappers import register_connection
from open_webui.env import (
OPEN_WEBUI_DIR,
DATABASE_URL,
DATABASE_SCHEMA,
SRC_LOG_LEVELS,
DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE,
@@ -14,7 +15,7 @@ from open_webui.env import (
DATABASE_POOL_TIMEOUT,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, types
from sqlalchemy import Dialect, create_engine, MetaData, types
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.pool import QueuePool, NullPool
@@ -99,7 +100,8 @@ else:
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
Base = declarative_base()
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
Base = declarative_base(metadata=metadata_obj)
Session = scoped_session(SessionLocal)
+16
View File
@@ -108,6 +108,7 @@ from open_webui.config import (
COMFYUI_WORKFLOW,
COMFYUI_WORKFLOW_NODES,
ENABLE_IMAGE_GENERATION,
ENABLE_IMAGE_PROMPT_GENERATION,
IMAGE_GENERATION_ENGINE,
IMAGE_GENERATION_MODEL,
IMAGE_SIZE,
@@ -199,6 +200,8 @@ from open_webui.config import (
ENABLE_SIGNUP,
ENABLE_LOGIN_FORM,
ENABLE_API_KEY,
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
API_KEY_ALLOWED_ENDPOINTS,
ENABLE_CHANNELS,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
@@ -223,6 +226,7 @@ from open_webui.config import (
LDAP_SERVER_LABEL,
LDAP_SERVER_HOST,
LDAP_SERVER_PORT,
LDAP_ATTRIBUTE_FOR_MAIL,
LDAP_ATTRIBUTE_FOR_USERNAME,
LDAP_SEARCH_FILTERS,
LDAP_SEARCH_BASE,
@@ -252,6 +256,7 @@ from open_webui.config import (
ENABLE_AUTOCOMPLETE_GENERATION,
TITLE_GENERATION_PROMPT_TEMPLATE,
TAGS_GENERATION_PROMPT_TEMPLATE,
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
@@ -392,7 +397,12 @@ app.state.OPENAI_MODELS = {}
app.state.config.WEBUI_URL = WEBUI_URL
app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
app.state.config.ENABLE_API_KEY = ENABLE_API_KEY
app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
)
app.state.config.API_KEY_ALLOWED_ENDPOINTS = API_KEY_ALLOWED_ENDPOINTS
app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
@@ -430,6 +440,7 @@ app.state.config.ENABLE_LDAP = ENABLE_LDAP
app.state.config.LDAP_SERVER_LABEL = LDAP_SERVER_LABEL
app.state.config.LDAP_SERVER_HOST = LDAP_SERVER_HOST
app.state.config.LDAP_SERVER_PORT = LDAP_SERVER_PORT
app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = LDAP_ATTRIBUTE_FOR_MAIL
app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = LDAP_ATTRIBUTE_FOR_USERNAME
app.state.config.LDAP_APP_DN = LDAP_APP_DN
app.state.config.LDAP_APP_PASSWORD = LDAP_APP_PASSWORD
@@ -565,6 +576,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
@@ -635,6 +647,10 @@ app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE
app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = (
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
)
app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = (
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
)
@@ -0,0 +1,70 @@
"""Update message & channel tables
Revision ID: 3781e22d8b01
Revises: 7826ab40b532
Create Date: 2024-12-30 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "3781e22d8b01"
down_revision = "7826ab40b532"
branch_labels = None
depends_on = None
def upgrade():
# Add 'type' column to the 'channel' table
op.add_column(
"channel",
sa.Column(
"type",
sa.Text(),
nullable=True,
),
)
# Add 'parent_id' column to the 'message' table for threads
op.add_column(
"message",
sa.Column("parent_id", sa.Text(), nullable=True),
)
op.create_table(
"message_reaction",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Unique reaction ID
sa.Column("user_id", sa.Text(), nullable=False), # User who reacted
sa.Column(
"message_id", sa.Text(), nullable=False
), # Message that was reacted to
sa.Column(
"name", sa.Text(), nullable=False
), # Reaction name (e.g. "thumbs_up")
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the reaction was added
)
op.create_table(
"channel_member",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column("channel_id", sa.Text(), nullable=False), # Associated channel
sa.Column("user_id", sa.Text(), nullable=False), # Associated user
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the user joined the channel
)
def downgrade():
# Revert 'type' column addition to the 'channel' table
op.drop_column("channel", "type")
op.drop_column("message", "parent_id")
op.drop_table("message_reaction")
op.drop_table("channel_member")
+6 -2
View File
@@ -21,6 +21,7 @@ class Channel(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text)
type = Column(Text, nullable=True)
name = Column(Text)
description = Column(Text, nullable=True)
@@ -38,9 +39,11 @@ class ChannelModel(BaseModel):
id: str
user_id: str
description: Optional[str] = None
type: Optional[str] = None
name: str
description: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
@@ -64,12 +67,13 @@ class ChannelForm(BaseModel):
class ChannelTable:
def insert_new_channel(
self, form_data: ChannelForm, user_id: str
self, type: Optional[str], form_data: ChannelForm, user_id: str
) -> Optional[ChannelModel]:
with get_db() as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
"type": type,
"name": form_data.name.lower(),
"id": str(uuid.uuid4()),
"user_id": user_id,
+12 -1
View File
@@ -212,6 +212,15 @@ class ChatTable:
return chat.chat.get("history", {}).get("messages", {}) or {}
def get_message_by_id_and_message_id(
self, id: str, message_id: str
) -> Optional[dict]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get("history", {}).get("messages", {}).get(message_id, {})
def upsert_message_to_chat_by_id_and_message_id(
self, id: str, message_id: str, message: dict
) -> Optional[ChatModel]:
@@ -384,7 +393,7 @@ class ChatTable:
limit: int = 50,
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
query = db.query(Chat).filter_by(user_id=user_id)
if not include_archived:
query = query.filter_by(archived=False)
@@ -460,6 +469,8 @@ class ChatTable:
def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]:
try:
with get_db() as db:
# it is possible that the shared link was deleted. hence,
# we check if the chat is still shared by checkng if a chat with the share_id exists
chat = db.query(Chat).filter_by(share_id=id).first()
if chat:
+21 -3
View File
@@ -80,12 +80,11 @@ class GroupResponse(BaseModel):
class GroupForm(BaseModel):
name: str
description: str
permissions: Optional[dict] = None
class GroupUpdateForm(GroupForm):
permissions: Optional[dict] = None
user_ids: Optional[list[str]] = None
admin_ids: Optional[list[str]] = None
class GroupTable:
@@ -95,7 +94,7 @@ class GroupTable:
with get_db() as db:
group = GroupModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude_none=True),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
@@ -189,5 +188,24 @@ class GroupTable:
except Exception:
return False
def remove_user_from_all_groups(self, user_id: str) -> bool:
with get_db() as db:
try:
groups = self.get_groups_by_member_id(user_id)
for group in groups:
group.user_ids.remove(user_id)
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
db.commit()
return True
except Exception:
return False
Groups = GroupTable()
+144 -6
View File
@@ -17,6 +17,25 @@ from sqlalchemy.sql import exists
####################
class MessageReaction(Base):
__tablename__ = "message_reaction"
id = Column(Text, primary_key=True)
user_id = Column(Text)
message_id = Column(Text)
name = Column(Text)
created_at = Column(BigInteger)
class MessageReactionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
message_id: str
name: str
created_at: int # timestamp in epoch
class Message(Base):
__tablename__ = "message"
id = Column(Text, primary_key=True)
@@ -24,6 +43,8 @@ class Message(Base):
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
content = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
@@ -39,6 +60,8 @@ class MessageModel(BaseModel):
user_id: str
channel_id: Optional[str] = None
parent_id: Optional[str] = None
content: str
data: Optional[dict] = None
meta: Optional[dict] = None
@@ -54,10 +77,23 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
content: str
parent_id: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
class Reactions(BaseModel):
name: str
user_ids: list[str]
count: int
class MessageResponse(MessageModel):
latest_reply_at: Optional[int]
reply_count: int
reactions: list[Reactions]
class MessageTable:
def insert_new_message(
self, form_data: MessageForm, channel_id: str, user_id: str
@@ -71,6 +107,7 @@ class MessageTable:
"id": id,
"user_id": user_id,
"channel_id": channel_id,
"parent_id": form_data.parent_id,
"content": form_data.content,
"data": form_data.data,
"meta": form_data.meta,
@@ -85,10 +122,40 @@ class MessageTable:
db.refresh(result)
return MessageModel.model_validate(result) if result else None
def get_message_by_id(self, id: str) -> Optional[MessageModel]:
def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
with get_db() as db:
message = db.get(Message, id)
return MessageModel.model_validate(message) if message else None
if not message:
return None
reactions = self.get_reactions_by_message_id(id)
replies = self.get_replies_by_message_id(id)
return MessageResponse(
**{
**MessageModel.model_validate(message).model_dump(),
"latest_reply_at": replies[0].created_at if replies else None,
"reply_count": len(replies),
"reactions": reactions,
}
)
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
with get_db() as db:
all_messages = (
db.query(Message)
.filter_by(parent_id=id)
.order_by(Message.created_at.desc())
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
return [
message.user_id
for message in db.query(Message).filter_by(parent_id=id).all()
]
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
@@ -96,7 +163,7 @@ class MessageTable:
with get_db() as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id)
.filter_by(channel_id=channel_id, parent_id=None)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
@@ -104,18 +171,28 @@ class MessageTable:
)
return [MessageModel.model_validate(message) for message in all_messages]
def get_messages_by_user_id(
self, user_id: str, skip: int = 0, limit: int = 50
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
with get_db() as db:
message = db.get(Message, parent_id)
if not message:
return []
all_messages = (
db.query(Message)
.filter_by(user_id=user_id)
.filter_by(channel_id=channel_id, parent_id=parent_id)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
# If length of all_messages is less than limit, then add the parent message
if len(all_messages) < limit:
all_messages.append(message)
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id(
@@ -131,9 +208,70 @@ class MessageTable:
db.refresh(message)
return MessageModel.model_validate(message) if message else None
def add_reaction_to_message(
self, id: str, user_id: str, name: str
) -> Optional[MessageReactionModel]:
with get_db() as db:
reaction_id = str(uuid.uuid4())
reaction = MessageReactionModel(
id=reaction_id,
user_id=user_id,
message_id=id,
name=name,
created_at=int(time.time_ns()),
)
result = MessageReaction(**reaction.model_dump())
db.add(result)
db.commit()
db.refresh(result)
return MessageReactionModel.model_validate(result) if result else None
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
with get_db() as db:
all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
reactions = {}
for reaction in all_reactions:
if reaction.name not in reactions:
reactions[reaction.name] = {
"name": reaction.name,
"user_ids": [],
"count": 0,
}
reactions[reaction.name]["user_ids"].append(reaction.user_id)
reactions[reaction.name]["count"] += 1
return [Reactions(**reaction) for reaction in reactions.values()]
def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str
) -> bool:
with get_db() as db:
db.query(MessageReaction).filter_by(
message_id=id, user_id=user_id, name=name
).delete()
db.commit()
return True
def delete_reactions_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
def delete_replies_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(Message).filter_by(parent_id=id).delete()
db.commit()
return True
def delete_message_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(Message).filter_by(id=id).delete()
# Delete all reactions to this message
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
+13 -1
View File
@@ -2,7 +2,12 @@ import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
@@ -268,9 +273,11 @@ class UsersTable:
def delete_user_by_id(self, id: str) -> bool:
try:
# Remove User from Groups
Groups.remove_user_from_all_groups(id)
# Delete User Chats
result = Chats.delete_chats_by_user_id(id)
if result:
with get_db() as db:
# Delete User
@@ -300,5 +307,10 @@ class UsersTable:
except Exception:
return None
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [user.id for user in users]
Users = UsersTable()
+18 -3
View File
@@ -11,10 +11,12 @@ from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriev
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.utils.misc import get_last_user_message
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import SRC_LOG_LEVELS, OFFLINE_MODE
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
@@ -199,7 +201,12 @@ def query_collection(
else:
pass
return merge_and_sort_query_results(results, k=k)
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
def query_collection_with_hybrid_search(
@@ -235,7 +242,12 @@ def query_collection_with_hybrid_search(
"Hybrid search failed for all collections. Using Non hybrid search as fallback."
)
return merge_and_sort_query_results(results, k=k, reverse=True)
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
def get_embedding_function(
@@ -375,6 +387,9 @@ def get_model_path(model: str, update_model: bool = False):
local_files_only = not update_model
if OFFLINE_MODE:
local_files_only = True
snapshot_kwargs = {
"cache_dir": cache_dir,
"local_files_only": local_files_only,
@@ -51,8 +51,8 @@ class ChromaClient:
def has_collection(self, collection_name: str) -> bool:
# Check if the collection exists based on the collection name.
collections = self.client.list_collections()
return collection_name in [collection.name for collection in collections]
collection_names = self.client.list_collections()
return collection_name in collection_names
def delete_collection(self, collection_name: str):
# Delete the collection based on the collection name.
@@ -7,13 +7,14 @@ from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import (
MILVUS_URI,
MILVUS_DB,
)
class MilvusClient:
def __init__(self):
self.collection_prefix = "open_webui"
self.client = Client(uri=MILVUS_URI)
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB)
def _result_to_get_result(self, result) -> GetResult:
ids = []
@@ -5,9 +5,11 @@ from sqlalchemy import (
create_engine,
Column,
Integer,
MetaData,
select,
text,
Text,
Table,
values,
)
from sqlalchemy.sql import true
@@ -17,11 +19,12 @@ from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
from sqlalchemy.dialects.postgresql import JSONB, array
from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import PGVECTOR_DB_URL
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
VECTOR_LENGTH = 1536
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
@@ -56,6 +59,9 @@ class PgvectorClient:
# Ensure the pgvector extension is available
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
# Check vector length consistency
self.check_vector_length()
# Create the tables if they do not exist
# Base.metadata.create_all requires a bind (engine or connection)
# Get the connection from the session
@@ -82,6 +88,41 @@ class PgvectorClient:
print(f"Error during initialization: {e}")
raise
def check_vector_length(self) -> None:
"""
Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
Raises an exception if there is a mismatch.
"""
metadata = MetaData()
try:
# Attempt to reflect the 'document_chunk' table
document_chunk_table = Table(
"document_chunk", metadata, autoload_with=self.session.bind
)
except NoSuchTableError:
# Table does not exist; no action needed
return
# Proceed to check the vector column
if "vector" in document_chunk_table.columns:
vector_column = document_chunk_table.columns["vector"]
vector_type = vector_column.type
if isinstance(vector_type, Vector):
db_vector_length = vector_type.dim
if db_vector_length != VECTOR_LENGTH:
raise Exception(
f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
"Cannot change vector size after initialization without migrating the data."
)
else:
raise Exception(
"The 'vector' column exists but is not of type 'Vector'."
)
else:
raise Exception(
"The 'vector' column does not exist in the 'document_chunk' table."
)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
# Adjust vector to have length VECTOR_LENGTH
current_length = len(vector)
+1 -1
View File
@@ -23,7 +23,7 @@ def search_bing(
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
mkt = locale
params = {"q": query, "mkt": mkt, "answerCount": count}
params = {"q": query, "mkt": mkt, "count": count}
headers = {"Ocp-Apim-Subscription-Key": subscription_key}
try:
+1 -1
View File
@@ -683,7 +683,7 @@
"age": "October 29, 2022",
"extra_snippets": [
"You can pass many options to the configure script; run ./configure --help to find out more. On macOS case-insensitive file systems and on Cygwin, the executable is called python.exe; elsewhere it's just python.",
"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or useable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or usable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
"To get an optimized build of Python, configure --enable-optimizations before you run make. This sets the default make targets up to enable Profile Guided Optimization (PGO) and may be used to auto-enable Link Time Optimization (LTO) on some platforms. For more details, see the sections below.",
"Copyright © 2001-2024 Python Software Foundation. All rights reserved."
]
+1 -1
View File
@@ -311,7 +311,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
voice_id = payload.get("voice", "")
if voice_id not in get_available_voices():
if voice_id not in get_available_voices(request):
raise HTTPException(
status_code=400,
detail="Invalid voice id",
+30 -4
View File
@@ -51,7 +51,7 @@ from open_webui.utils.access_control import get_permissions
from typing import Optional, List
from ssl import CERT_REQUIRED, PROTOCOL_TLS
from ldap3 import Server, Connection, ALL, Tls
from ldap3 import Server, Connection, NONE, Tls
from ldap3.utils.conv import escape_filter_chars
router = APIRouter()
@@ -170,6 +170,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL
LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME
LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE
LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS
@@ -201,7 +202,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
server = Server(
host=LDAP_SERVER_HOST,
port=LDAP_SERVER_PORT,
get_info=ALL,
get_info=NONE,
use_ssl=LDAP_USE_TLS,
tls=tls,
)
@@ -218,7 +219,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
search_success = connection_app.search(
search_base=LDAP_SEARCH_BASE,
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
attributes=[f"{LDAP_ATTRIBUTE_FOR_USERNAME}", "mail", "cn"],
attributes=[
f"{LDAP_ATTRIBUTE_FOR_USERNAME}",
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
"cn",
],
)
if not search_success:
@@ -226,7 +231,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
entry = connection_app.entries[0]
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
mail = str(entry["mail"])
mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
if not mail or mail == "" or mail == "[]":
raise HTTPException(400, f"User {form_data.user} does not have mail.")
cn = str(entry["cn"])
user_dn = entry.entry_dn
@@ -616,6 +623,8 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
@@ -629,6 +638,8 @@ class AdminConfig(BaseModel):
WEBUI_URL: str
ENABLE_SIGNUP: bool
ENABLE_API_KEY: bool
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
API_KEY_ALLOWED_ENDPOINTS: str
ENABLE_CHANNELS: bool
DEFAULT_USER_ROLE: str
JWT_EXPIRES_IN: str
@@ -643,7 +654,15 @@ async def update_admin_config(
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
request.app.state.config.ENABLE_API_KEY = form_data.ENABLE_API_KEY
request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
form_data.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
)
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS = (
form_data.API_KEY_ALLOWED_ENDPOINTS
)
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
@@ -665,6 +684,8 @@ async def update_admin_config(
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
@@ -677,6 +698,7 @@ class LdapServerConfig(BaseModel):
label: str
host: str
port: Optional[int] = None
attribute_for_mail: str = "mail"
attribute_for_username: str = "uid"
app_dn: str
app_dn_password: str
@@ -693,6 +715,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
"label": request.app.state.config.LDAP_SERVER_LABEL,
"host": request.app.state.config.LDAP_SERVER_HOST,
"port": request.app.state.config.LDAP_SERVER_PORT,
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
"app_dn": request.app.state.config.LDAP_APP_DN,
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,
@@ -711,6 +734,7 @@ async def update_ldap_server(
required_fields = [
"label",
"host",
"attribute_for_mail",
"attribute_for_username",
"app_dn",
"app_dn_password",
@@ -729,6 +753,7 @@ async def update_ldap_server(
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
request.app.state.config.LDAP_SERVER_HOST = form_data.host
request.app.state.config.LDAP_SERVER_PORT = form_data.port
request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
form_data.attribute_for_username
)
@@ -744,6 +769,7 @@ async def update_ldap_server(
"label": request.app.state.config.LDAP_SERVER_LABEL,
"host": request.app.state.config.LDAP_SERVER_HOST,
"port": request.app.state.config.LDAP_SERVER_PORT,
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
"app_dn": request.app.state.config.LDAP_APP_DN,
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,
+329 -13
View File
@@ -11,7 +11,12 @@ from open_webui.socket.main import sio, get_user_ids_from_room
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
from open_webui.models.messages import Messages, MessageModel, MessageForm
from open_webui.models.messages import (
Messages,
MessageModel,
MessageResponse,
MessageForm,
)
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
@@ -49,7 +54,7 @@ async def get_channels(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[ChannelModel])
async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
try:
channel = Channels.insert_new_channel(form_data, user.id)
channel = Channels.insert_new_channel(None, form_data, user.id)
return ChannelModel(**channel.model_dump())
except Exception as e:
log.exception(e)
@@ -134,11 +139,11 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
############################
class MessageUserModel(MessageModel):
class MessageUserResponse(MessageResponse):
user: UserNameResponse
@router.get("/{id}/messages", response_model=list[MessageUserModel])
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
async def get_channel_messages(
id: str, skip: int = 0, limit: int = 50, user=Depends(get_verified_user)
):
@@ -164,10 +169,16 @@ async def get_channel_messages(
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
replies = Messages.get_replies_by_message_id(message.id)
latest_reply_at = replies[0].created_at if replies else None
messages.append(
MessageUserModel(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": len(replies),
"latest_reply_at": latest_reply_at,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
@@ -236,10 +247,17 @@ async def post_new_message(
"message_id": message.id,
"data": {
"type": "message",
"data": {
**message.model_dump(),
"user": UserNameResponse(**user.model_dump()).model_dump(),
},
"data": MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(
message.id
),
"user": UserNameResponse(**user.model_dump()),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@@ -251,6 +269,35 @@ async def post_new_message(
to=f"channel:{channel.id}",
)
if message.parent_id:
# If this message is a reply, emit to the parent message as well
parent_message = Messages.get_message_by_id(message.parent_id)
if parent_message:
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
background_tasks.add_task(
@@ -269,6 +316,101 @@ async def post_new_message(
)
############################
# GetChannelMessage
############################
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse])
async def get_channel_message(
id: str, message_id: str, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
),
}
)
############################
# GetChannelThreadMessages
############################
@router.get(
"/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse]
)
async def get_channel_thread_messages(
id: str,
message_id: str,
skip: int = 0,
limit: int = 50,
user=Depends(get_verified_user),
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit)
users = {}
messages = []
for message in message_list:
if message.user_id not in users:
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
messages.append(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
)
return messages
############################
# UpdateMessageById
############################
@@ -306,6 +448,8 @@ async def update_message_by_id(
try:
message = Messages.update_message_by_id(message_id, form_data)
message = Messages.get_message_by_id(message_id)
if message:
await sio.emit(
"channel-events",
@@ -314,10 +458,14 @@ async def update_message_by_id(
"message_id": message.id,
"data": {
"type": "message:update",
"data": {
**message.model_dump(),
"user": UserNameResponse(**user.model_dump()).model_dump(),
},
"data": MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**user.model_dump()
).model_dump(),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@@ -333,6 +481,145 @@ async def update_message_by_id(
)
############################
# AddReactionToMessage
############################
class ReactionForm(BaseModel):
name: str
@router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool)
async def add_reaction_to_message(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.add_reaction_to_message(message_id, user.id, form_data.name)
message = Messages.get_message_by_id(message_id)
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:reaction:add",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# RemoveReactionById
############################
@router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool)
async def remove_reaction_by_id_and_user_id_and_name(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.remove_reaction_by_id_and_user_id_and_name(
message_id, user.id, form_data.name
)
message = Messages.get_message_by_id(message_id)
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:reaction:remove",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# DeleteMessageById
############################
@@ -386,6 +673,35 @@ async def delete_message_by_id(
to=f"channel:{channel.id}",
)
if message.parent_id:
# If this message is a reply, emit to the parent message as well
parent_message = Messages.get_message_by_id(message.parent_id)
if parent_message:
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
+24
View File
@@ -463,6 +463,30 @@ async def clone_chat_by_id(id: str, user=Depends(get_verified_user)):
)
############################
# CloneSharedChatById
############################
@router.post("/{id}/clone/shared", response_model=Optional[ChatResponse])
async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
chat = Chats.get_chat_by_share_id(id)
if chat:
updated_chat = {
**chat.chat,
"originalChatId": chat.id,
"branchPointMessageId": chat.chat["history"]["currentId"],
"title": f"Clone of {chat.title}",
}
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# ArchiveChat
############################
+3 -1
View File
@@ -345,10 +345,12 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
# We should add Chroma cleanup here
result = Files.delete_file_by_id(id)
if result:
try:
Storage.delete_file(file.filename)
Storage.delete_file(file.path)
except Exception as e:
log.exception(e)
log.error(f"Error deleting files")
+5
View File
@@ -2,6 +2,8 @@ import os
from pathlib import Path
from typing import Optional
from open_webui.models.users import Users
from open_webui.models.groups import (
Groups,
GroupForm,
@@ -80,6 +82,9 @@ async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
):
try:
if form_data.user_ids:
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
group = Groups.update_group_by_id(id, form_data)
if group:
return group
+24 -7
View File
@@ -43,6 +43,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
return {
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
@@ -86,6 +87,7 @@ class ComfyUIConfigForm(BaseModel):
class ConfigForm(BaseModel):
enabled: bool
engine: str
prompt_generation: bool
openai: OpenAIConfigForm
automatic1111: Automatic1111ConfigForm
comfyui: ComfyUIConfigForm
@@ -98,6 +100,10 @@ async def update_config(
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.engine
request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.enabled
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = (
form_data.prompt_generation
)
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
form_data.openai.OPENAI_API_BASE_URL
)
@@ -137,6 +143,7 @@ async def update_config(
return {
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
@@ -200,7 +207,7 @@ def set_image_model(request: Request, model: str):
log.info(f"Setting image model to {model}")
request.app.state.config.IMAGE_GENERATION_MODEL = model
if request.app.state.config.IMAGE_GENERATION_ENGINE in ["", "automatic1111"]:
api_auth = get_automatic1111_api_auth()
api_auth = get_automatic1111_api_auth(request)
r = requests.get(
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
headers={"authorization": api_auth},
@@ -236,7 +243,7 @@ def get_image_model(request):
try:
r = requests.get(
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
headers={"authorization": get_automatic1111_api_auth()},
headers={"authorization": get_automatic1111_api_auth(request)},
)
options = r.json()
return options["sd_model_checkpoint"]
@@ -354,7 +361,7 @@ def get_models(request: Request, user=Depends(get_verified_user)):
):
r = requests.get(
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models",
headers={"authorization": get_automatic1111_api_auth()},
headers={"authorization": get_automatic1111_api_auth(request)},
)
models = r.json()
return list(
@@ -408,10 +415,14 @@ def save_b64_image(b64_str):
return None
def save_url_image(url):
def save_url_image(url, headers=None):
image_id = str(uuid.uuid4())
try:
r = requests.get(url)
if headers:
r = requests.get(url, headers=headers)
else:
r = requests.get(url)
r.raise_for_status()
if r.headers["content-type"].split("/")[0] == "image":
mime_type = r.headers["content-type"]
@@ -535,7 +546,13 @@ async def image_generations(
images = []
for image in res["data"]:
image_filename = save_url_image(image["url"])
headers = None
if request.app.state.config.COMFYUI_API_KEY:
headers = {
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
}
image_filename = save_url_image(image["url"], headers)
images.append({"url": f"/cache/image/generations/{image_filename}"})
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
@@ -578,7 +595,7 @@ async def image_generations(
requests.post,
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img",
json=data,
headers={"authorization": get_automatic1111_api_auth()},
headers={"authorization": get_automatic1111_api_auth(request)},
)
res = r.json()
+51 -8
View File
@@ -17,7 +17,7 @@ from open_webui.routers.retrieval import (
process_files_batch,
BatchProcessFilesForm,
)
from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user
@@ -25,6 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.models import Models, ModelForm
log = logging.getLogger(__name__)
@@ -212,8 +213,12 @@ async def update_knowledge_by_id(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
# Is the user the original creator, in a group with write access, or an admin
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -419,11 +424,16 @@ def remove_file_from_knowledge_by_id(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
result = VECTOR_DB_CLIENT.query(
collection_name=knowledge.id,
filter={"file_id": form_data.file_id},
)
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
# Delete physical file
if file.path:
Storage.delete_file(file.path)
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
if knowledge:
@@ -480,6 +490,36 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})")
# Get all models
models = Models.get_all_models()
log.info(f"Found {len(models)} models to check for knowledge base {id}")
# Update models that reference this knowledge base
for model in models:
if model.meta and hasattr(model.meta, "knowledge"):
knowledge_list = model.meta.knowledge or []
# Filter out the deleted knowledge base
updated_knowledge = [k for k in knowledge_list if k.get("id") != id]
# If the knowledge list changed, update the model
if len(updated_knowledge) != len(knowledge_list):
log.info(f"Updating model {model.id} to remove knowledge base {id}")
model.meta.knowledge = updated_knowledge
# Create a ModelForm for the update
model_form = ModelForm(
id=model.id,
name=model.name,
base_model_id=model.base_model_id,
meta=model.meta,
params=model.params,
access_control=model.access_control,
is_active=model.is_active,
)
Models.update_model_by_id(model.id, model_form)
# Clean up vector DB
try:
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
except Exception as e:
@@ -527,6 +567,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse])
def add_files_to_knowledge_batch(
request: Request,
id: str,
form_data: list[KnowledgeFileIdForm],
user=Depends(get_verified_user),
@@ -562,7 +603,9 @@ def add_files_to_knowledge_batch(
# Process files
try:
result = process_files_batch(
BatchProcessFilesForm(files=files, collection_name=id)
request=request,
form_data=BatchProcessFilesForm(files=files, collection_name=id),
user=user,
)
except Exception as e:
log.error(
+70 -36
View File
@@ -152,10 +152,12 @@ async def send_post_request(
)
def get_api_key(url, configs):
def get_api_key(idx, url, configs):
parsed_url = urlparse(url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
return configs.get(base_url, {}).get("key", None)
return configs.get(str(idx), configs.get(base_url, {})).get(
"key", None
) # Legacy support
##########################################
@@ -238,11 +240,13 @@ async def update_config(
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS
# Remove any extra configs
config_urls = request.app.state.config.OLLAMA_API_CONFIGS.keys()
for url in list(request.app.state.config.OLLAMA_BASE_URLS):
if url not in config_urls:
request.app.state.config.OLLAMA_API_CONFIGS.pop(url, None)
# Remove the API configs that are not in the API URLS
keys = list(map(str, range(len(request.app.state.config.OLLAMA_BASE_URLS))))
request.app.state.config.OLLAMA_API_CONFIGS = {
key: value
for key, value in request.app.state.config.OLLAMA_API_CONFIGS.items()
if key in keys
}
return {
"ENABLE_OLLAMA_API": request.app.state.config.ENABLE_OLLAMA_API,
@@ -256,12 +260,19 @@ async def get_all_models(request: Request):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
if url not in request.app.state.config.OLLAMA_API_CONFIGS:
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) or (
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
):
request_tasks.append(send_get_request(f"{url}/api/tags"))
else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True)
key = api_config.get("key", None)
@@ -275,7 +286,12 @@ async def get_all_models(request: Request):
for idx, response in enumerate(responses):
if response:
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
model_ids = api_config.get("model_ids", [])
@@ -349,7 +365,7 @@ async def get_ollama_tags(
models = await get_all_models(request)
else:
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
r = None
try:
@@ -393,11 +409,14 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
request_tasks = [
send_get_request(
f"{url}/api/version",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
"key", None
),
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
)
for url in request.app.state.config.OLLAMA_BASE_URLS
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
responses = await asyncio.gather(*request_tasks)
responses = list(filter(lambda x: x is not None, responses))
@@ -454,11 +473,14 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u
request_tasks = [
send_get_request(
f"{url}/api/ps",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
"key", None
),
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
)
for url in request.app.state.config.OLLAMA_BASE_URLS
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
responses = await asyncio.gather(*request_tasks)
@@ -488,7 +510,7 @@ async def pull_model(
return await send_post_request(
url=f"{url}/api/pull",
payload=json.dumps(payload),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -524,7 +546,7 @@ async def push_model(
return await send_post_request(
url=f"{url}/api/push",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -549,7 +571,7 @@ async def create_model(
return await send_post_request(
url=f"{url}/api/create",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -579,7 +601,7 @@ async def copy_model(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -634,7 +656,7 @@ async def delete_model(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -684,7 +706,7 @@ async def show_model_info(
url_idx = random.choice(models[form_data.name]["urls"])
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -753,7 +775,7 @@ async def embed(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -822,7 +844,7 @@ async def embeddings(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -897,7 +919,10 @@ async def generate_completion(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
@@ -906,7 +931,7 @@ async def generate_completion(
return await send_post_request(
url=f"{url}/api/generate",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -1005,7 +1030,10 @@ async def generate_chat_completion(
payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
@@ -1015,7 +1043,7 @@ async def generate_chat_completion(
url=f"{url}/api/chat",
payload=json.dumps(payload),
stream=form_data.stream,
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
content_type="application/x-ndjson",
)
@@ -1104,7 +1132,10 @@ async def generate_openai_completion(
payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
@@ -1115,7 +1146,7 @@ async def generate_openai_completion(
url=f"{url}/v1/completions",
payload=json.dumps(payload),
stream=payload.get("stream", False),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -1178,7 +1209,10 @@ async def generate_openai_chat_completion(
payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
@@ -1188,7 +1222,7 @@ async def generate_openai_chat_completion(
url=f"{url}/v1/chat/completions",
payload=json.dumps(payload),
stream=payload.get("stream", False),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
+29 -12
View File
@@ -145,11 +145,13 @@ async def update_config(
request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS
# Remove any extra configs
config_urls = request.app.state.config.OPENAI_API_CONFIGS.keys()
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
if url not in config_urls:
request.app.state.config.OPENAI_API_CONFIGS.pop(url, None)
# Remove the API configs that are not in the API URLS
keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS))))
request.app.state.config.OPENAI_API_CONFIGS = {
key: value
for key, value in request.app.state.config.OPENAI_API_CONFIGS.items()
if key in keys
}
return {
"ENABLE_OPENAI_API": request.app.state.config.ENABLE_OPENAI_API,
@@ -264,14 +266,21 @@ async def get_all_models_responses(request: Request) -> list:
request_tasks = []
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
if url not in request.app.state.config.OPENAI_API_CONFIGS:
if (str(idx) not in request.app.state.config.OPENAI_API_CONFIGS) or (
url not in request.app.state.config.OPENAI_API_CONFIGS # Legacy support
):
request_tasks.append(
send_get_request(
f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx]
)
)
else:
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True)
model_ids = api_config.get("model_ids", [])
@@ -310,7 +319,12 @@ async def get_all_models_responses(request: Request) -> list:
for idx, response in enumerate(responses):
if response:
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
@@ -573,6 +587,7 @@ async def generate_chat_completion(
detail="Model not found",
)
await get_all_models(request)
model = request.app.state.OPENAI_MODELS.get(model_id)
if model:
idx = model["urlIdx"]
@@ -584,7 +599,10 @@ async def generate_chat_completion(
# Get the API config for the model
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
@@ -613,9 +631,8 @@ async def generate_chat_completion(
payload["max_tokens"] = payload["max_completion_tokens"]
del payload["max_completion_tokens"]
# TODO: check if below is needed
# if "max_tokens" in payload and "max_completion_tokens" in payload:
# del payload["max_tokens"]
if "max_tokens" in payload and "max_completion_tokens" in payload:
del payload["max_tokens"]
# Convert the modified body back to JSON
payload = json.dumps(payload)
+3 -7
View File
@@ -124,18 +124,14 @@ def process_pipeline_outlet_filter(request, payload, user, models):
f"{url}/{filter['id']}/filter/outlet",
headers={"Authorization": f"Bearer {key}"},
json={
"user": {
"id": user.id,
"name": user.name,
"email": user.email,
"role": user.role,
},
"body": data,
"user": user,
"body": payload,
},
)
r.raise_for_status()
data = r.json()
payload = data
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
+6 -1
View File
@@ -112,7 +112,12 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if prompt.user_id != user.id and user.role != "admin":
# Is the user the original creator, in a group with write access, or an admin
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+7 -3
View File
@@ -347,7 +347,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
return {
"status": True,
"pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
"ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"content_extraction": {
"engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
"tika_server_url": request.app.state.config.TIKA_SERVER_URL,
@@ -384,7 +384,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"serply_api_key": request.app.state.config.SERPLY_API_KEY,
"tavily_api_key": request.app.state.config.TAVILY_API_KEY,
"searchapi_api_key": request.app.state.config.SEARCHAPI_API_KEY,
"seaarchapi_engine": request.app.state.config.SEARCHAPI_ENGINE,
"searchapi_engine": request.app.state.config.SEARCHAPI_ENGINE,
"jina_api_key": request.app.state.config.JINA_API_KEY,
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
@@ -1458,6 +1458,7 @@ class BatchProcessFilesResponse(BaseModel):
@router.post("/process/files/batch")
def process_files_batch(
request: Request,
form_data: BatchProcessFilesForm,
user=Depends(get_verified_user),
) -> BatchProcessFilesResponse:
@@ -1504,7 +1505,10 @@ def process_files_batch(
if all_docs:
try:
save_docs_to_vector_db(
docs=all_docs, collection_name=collection_name, add=True
request=request,
docs=all_docs,
collection_name=collection_name,
add=True,
)
# Update all files with collection name
+66 -1
View File
@@ -9,6 +9,7 @@ from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.task import (
title_generation_template,
query_generation_template,
image_prompt_generation_template,
autocomplete_generation_template,
tags_generation_template,
emoji_generation_template,
@@ -23,6 +24,7 @@ from open_webui.utils.task import get_task_model_id
from open_webui.config import (
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE,
DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE,
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE,
DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE,
@@ -50,6 +52,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
"TASK_MODEL": request.app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
@@ -65,6 +68,7 @@ class TaskConfigForm(BaseModel):
TASK_MODEL: Optional[str]
TASK_MODEL_EXTERNAL: Optional[str]
TITLE_GENERATION_PROMPT_TEMPLATE: str
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: str
ENABLE_AUTOCOMPLETE_GENERATION: bool
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int
TAGS_GENERATION_PROMPT_TEMPLATE: str
@@ -114,6 +118,7 @@ async def update_task_config(
"TASK_MODEL": request.app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
@@ -256,6 +261,66 @@ async def generate_chat_tags(
)
@router.post("/image_prompt/completions")
async def generate_image_prompt(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
)
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(
f"generating image prompt using model {task_model_id} for user {user.email} "
)
if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != "":
template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
content = image_prompt_generation_template(
template,
form_data["messages"],
user={
"name": user.name,
},
)
payload = {
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"metadata": {
"task": str(TASKS.IMAGE_PROMPT_GENERATION),
"task_body": form_data,
"chat_id": form_data.get("chat_id", None),
},
}
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
log.error("Exception occurred", exc_info=True)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": "An internal error has occurred."},
)
@router.post("/queries/completions")
async def generate_queries(
request: Request, form_data: dict, user=Depends(get_verified_user)
@@ -499,8 +564,8 @@ async def generate_moa_response(
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": form_data.get("stream", False),
"chat_id": form_data.get("chat_id", None),
"metadata": {
"chat_id": form_data.get("chat_id", None),
"task": str(TASKS.MOA_RESPONSE_GENERATION),
"task_body": form_data,
},
+7 -2
View File
@@ -70,7 +70,7 @@ async def create_new_tools(
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS
user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -165,7 +165,12 @@ async def update_tools_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if tools.user_id != user.id and user.role != "admin":
# Is the user the original creator, in a group with write access, or an admin
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
+43 -12
View File
@@ -10,6 +10,9 @@ from open_webui.models.users import (
UserSettings,
UserUpdateForm,
)
from open_webui.socket.main import get_active_status_by_user_id
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -27,7 +30,11 @@ router = APIRouter()
@router.get("/", response_model=list[UserModel])
async def get_users(skip: int = 0, limit: int = 50, user=Depends(get_admin_user)):
async def get_users(
skip: Optional[int] = None,
limit: Optional[int] = None,
user=Depends(get_admin_user),
):
return Users.get_users(skip, limit)
@@ -55,27 +62,44 @@ async def get_user_permissisions(user=Depends(get_verified_user)):
# User Default Permissions
############################
class WorkspacePermissions(BaseModel):
models: bool
knowledge: bool
prompts: bool
tools: bool
models: bool = False
knowledge: bool = False
prompts: bool = False
tools: bool = False
class ChatPermissions(BaseModel):
file_upload: bool
delete: bool
edit: bool
temporary: bool
controls: bool = True
file_upload: bool = True
delete: bool = True
edit: bool = True
temporary: bool = True
class FeaturesPermissions(BaseModel):
web_search: bool = True
image_generation: bool = True
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
chat: ChatPermissions
features: FeaturesPermissions
@router.get("/default/permissions")
@router.get("/default/permissions", response_model=UserPermissions)
async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
return request.app.state.config.USER_PERMISSIONS
return {
"workspace": WorkspacePermissions(
**request.app.state.config.USER_PERMISSIONS.get("workspace", {})
),
"chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
),
"features": FeaturesPermissions(
**request.app.state.config.USER_PERMISSIONS.get("features", {})
),
}
@router.post("/default/permissions")
@@ -192,6 +216,7 @@ async def update_user_info_by_session_user(
class UserResponse(BaseModel):
name: str
profile_image_url: str
active: Optional[bool] = None
@router.get("/{user_id}", response_model=UserResponse)
@@ -212,7 +237,13 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
user = Users.get_user_by_id(user_id)
if user:
return UserResponse(name=user.name, profile_image_url=user.profile_image_url)
return UserResponse(
**{
"name": user.name,
"profile_image_url": user.profile_image_url,
"active": get_active_status_by_user_id(user_id),
}
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
+78 -15
View File
@@ -4,7 +4,7 @@ import logging
import sys
import time
from open_webui.models.users import Users
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
@@ -152,14 +152,14 @@ async def connect(sid, environ, auth):
user = Users.get_user_by_id(data["id"])
if user:
SESSION_POOL[sid] = user.id
SESSION_POOL[sid] = user.model_dump()
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
USER_POOL[user.id] = [sid]
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
await sio.emit("user-count", {"count": len(USER_POOL.items())})
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
await sio.emit("usage", {"models": get_models_in_use()})
@@ -178,7 +178,7 @@ async def user_join(sid, data):
if not user:
return
SESSION_POOL[sid] = user.id
SESSION_POOL[sid] = user.model_dump()
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
@@ -192,7 +192,7 @@ async def user_join(sid, data):
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
await sio.emit("user-count", {"count": len(USER_POOL.items())})
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
return {"id": user.id, "name": user.name}
@@ -217,28 +217,52 @@ async def join_channel(sid, data):
await sio.enter_room(sid, f"channel:{channel.id}")
@sio.on("user-count")
async def user_count(sid):
await sio.emit("user-count", {"count": len(USER_POOL.items())})
@sio.on("channel-events")
async def channel_events(sid, data):
room = f"channel:{data['channel_id']}"
participants = sio.manager.get_participants(
namespace="/",
room=room,
)
sids = [sid for sid, _ in participants]
if sid not in sids:
return
event_data = data["data"]
event_type = event_data["type"]
if event_type == "typing":
await sio.emit(
"channel-events",
{
"channel_id": data["channel_id"],
"message_id": data.get("message_id", None),
"data": event_data,
"user": UserNameResponse(**SESSION_POOL[sid]).model_dump(),
},
room=room,
)
@sio.on("chat")
async def chat(sid, data):
print("chat", sid, SESSION_POOL[sid], data)
@sio.on("user-list")
async def user_list(sid):
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
@sio.event
async def disconnect(sid):
if sid in SESSION_POOL:
user_id = SESSION_POOL[sid]
user = SESSION_POOL[sid]
del SESSION_POOL[sid]
user_id = user["id"]
USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid]
if len(USER_POOL[user_id]) == 0:
del USER_POOL[user_id]
await sio.emit("user-count", {"count": len(USER_POOL)})
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
else:
pass
# print(f"Unknown session ID {sid} disconnected")
@@ -269,6 +293,34 @@ def get_event_emitter(request_info):
event_data.get("data", {}),
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
return __event_emitter__
@@ -289,7 +341,10 @@ def get_event_call(request_info):
def get_user_id_from_session_pool(sid):
return SESSION_POOL.get(sid)
user = SESSION_POOL.get(sid)
if user:
return user["id"]
return None
def get_user_ids_from_room(room):
@@ -299,6 +354,14 @@ def get_user_ids_from_room(room):
)
active_user_ids = list(
set([SESSION_POOL.get(session_id[0]) for session_id in active_session_ids])
set(
[SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
)
)
return active_user_ids
def get_active_status_by_user_id(user_id):
if user_id in USER_POOL:
return True
return False
+1 -1
View File
@@ -26,7 +26,7 @@ class RedisLock:
def release_lock(self):
lock_value = self.redis.get(self.lock_name)
if lock_value and lock_value.decode("utf-8") == self.lock_id:
if lock_value and lock_value == self.lock_id:
self.redis.delete(self.lock_name)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

+168 -113
View File
@@ -1,121 +1,73 @@
import os
import shutil
import json
from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple
import boto3
from botocore.exceptions import ClientError
import shutil
from typing import BinaryIO, Tuple, Optional, Union
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import (
STORAGE_PROVIDER,
S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY,
S3_BUCKET_NAME,
S3_REGION_NAME,
S3_ENDPOINT_URL,
S3_REGION_NAME,
S3_SECRET_ACCESS_KEY,
GCS_BUCKET_NAME,
GOOGLE_APPLICATION_CREDENTIALS_JSON,
STORAGE_PROVIDER,
UPLOAD_DIR,
)
from google.cloud import storage
from google.cloud.exceptions import GoogleCloudError, NotFound
from open_webui.constants import ERROR_MESSAGES
import boto3
from botocore.exceptions import ClientError
from typing import BinaryIO, Tuple, Optional
class StorageProvider(ABC):
@abstractmethod
def get_file(self, file_path: str) -> str:
pass
@abstractmethod
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
pass
@abstractmethod
def delete_all_files(self) -> None:
pass
@abstractmethod
def delete_file(self, file_path: str) -> None:
pass
class StorageProvider:
def __init__(self, provider: Optional[str] = None):
self.storage_provider: str = provider or STORAGE_PROVIDER
self.s3_client = None
self.s3_bucket_name: Optional[str] = None
if self.storage_provider == "s3":
self._initialize_s3()
def _initialize_s3(self) -> None:
"""Initializes the S3 client and bucket name if using S3 storage."""
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
)
self.bucket_name = S3_BUCKET_NAME
def _upload_to_s3(self, file_path: str, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
self.s3_client.upload_file(file_path, self.bucket_name, filename)
return (
open(file_path, "rb").read(),
"s3://" + self.bucket_name + "/" + filename,
)
except ClientError as e:
raise RuntimeError(f"Error uploading file to S3: {e}")
def _upload_to_local(self, contents: bytes, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to local storage."""
class LocalStorageProvider(StorageProvider):
@staticmethod
def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]:
contents = file.read()
if not contents:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
file_path = f"{UPLOAD_DIR}/{filename}"
with open(file_path, "wb") as f:
f.write(contents)
return contents, file_path
def _get_file_from_s3(self, file_path: str) -> str:
"""Handles downloading of the file from S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
bucket_name, key = file_path.split("//")[1].split("/")
local_file_path = f"{UPLOAD_DIR}/{key}"
self.s3_client.download_file(bucket_name, key, local_file_path)
return local_file_path
except ClientError as e:
raise RuntimeError(f"Error downloading file from S3: {e}")
def _get_file_from_local(self, file_path: str) -> str:
@staticmethod
def get_file(file_path: str) -> str:
"""Handles downloading of the file from local storage."""
return file_path
def _delete_from_s3(self, filename: str) -> None:
"""Handles deletion of the file from S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
except ClientError as e:
raise RuntimeError(f"Error deleting file from S3: {e}")
def _delete_from_local(self, filename: str) -> None:
@staticmethod
def delete_file(file_path: str) -> None:
"""Handles deletion of the file from local storage."""
filename = file_path.split("/")[-1]
file_path = f"{UPLOAD_DIR}/{filename}"
if os.path.isfile(file_path):
os.remove(file_path)
else:
print(f"File {file_path} not found in local storage.")
def _delete_all_from_s3(self) -> None:
"""Handles deletion of all files from S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
if "Contents" in response:
for content in response["Contents"]:
self.s3_client.delete_object(
Bucket=self.bucket_name, Key=content["Key"]
)
except ClientError as e:
raise RuntimeError(f"Error deleting all files from S3: {e}")
def _delete_all_from_local(self) -> None:
@staticmethod
def delete_all_files() -> None:
"""Handles deletion of all files from local storage."""
if os.path.exists(UPLOAD_DIR):
for filename in os.listdir(UPLOAD_DIR):
@@ -130,38 +82,141 @@ class StorageProvider:
else:
print(f"Directory {UPLOAD_DIR} not found in local storage.")
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Uploads a file either to S3 or the local file system."""
contents = file.read()
if not contents:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
contents, file_path = self._upload_to_local(contents, filename)
if self.storage_provider == "s3":
return self._upload_to_s3(file_path, filename)
return contents, file_path
class S3StorageProvider(StorageProvider):
def __init__(self):
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
)
self.bucket_name = S3_BUCKET_NAME
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to S3 storage."""
_, file_path = LocalStorageProvider.upload_file(file, filename)
try:
self.s3_client.upload_file(file_path, self.bucket_name, filename)
return (
open(file_path, "rb").read(),
"s3://" + self.bucket_name + "/" + filename,
)
except ClientError as e:
raise RuntimeError(f"Error uploading file to S3: {e}")
def get_file(self, file_path: str) -> str:
"""Downloads a file either from S3 or the local file system and returns the file path."""
if self.storage_provider == "s3":
return self._get_file_from_s3(file_path)
return self._get_file_from_local(file_path)
"""Handles downloading of the file from S3 storage."""
try:
bucket_name, key = file_path.split("//")[1].split("/")
local_file_path = f"{UPLOAD_DIR}/{key}"
self.s3_client.download_file(bucket_name, key, local_file_path)
return local_file_path
except ClientError as e:
raise RuntimeError(f"Error downloading file from S3: {e}")
def delete_file(self, filename: str) -> None:
"""Deletes a file either from S3 or the local file system."""
if self.storage_provider == "s3":
self._delete_from_s3(filename)
def delete_file(self, file_path: str) -> None:
"""Handles deletion of the file from S3 storage."""
filename = file_path.split("/")[-1]
try:
self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
except ClientError as e:
raise RuntimeError(f"Error deleting file from S3: {e}")
# Always delete from local storage
self._delete_from_local(filename)
LocalStorageProvider.delete_file(file_path)
def delete_all_files(self) -> None:
"""Deletes all files from the storage."""
if self.storage_provider == "s3":
self._delete_all_from_s3()
"""Handles deletion of all files from S3 storage."""
try:
response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
if "Contents" in response:
for content in response["Contents"]:
self.s3_client.delete_object(
Bucket=self.bucket_name, Key=content["Key"]
)
except ClientError as e:
raise RuntimeError(f"Error deleting all files from S3: {e}")
# Always delete from local storage
self._delete_all_from_local()
LocalStorageProvider.delete_all_files()
Storage = StorageProvider(provider=STORAGE_PROVIDER)
class GCSStorageProvider(StorageProvider):
def __init__(self):
self.bucket_name = GCS_BUCKET_NAME
if GOOGLE_APPLICATION_CREDENTIALS_JSON:
self.gcs_client = storage.Client.from_service_account_info(
info=json.loads(GOOGLE_APPLICATION_CREDENTIALS_JSON)
)
else:
# if no credentials json is provided, credentials will be picked up from the environment
# if running on local environment, credentials would be user credentials
# if running on a Compute Engine instance, credentials would be from Google Metadata server
self.gcs_client = storage.Client()
self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME)
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to GCS storage."""
contents, file_path = LocalStorageProvider.upload_file(file, filename)
try:
blob = self.bucket.blob(filename)
blob.upload_from_filename(file_path)
return contents, "gs://" + self.bucket_name + "/" + filename
except GoogleCloudError as e:
raise RuntimeError(f"Error uploading file to GCS: {e}")
def get_file(self, file_path: str) -> str:
"""Handles downloading of the file from GCS storage."""
try:
filename = file_path.removeprefix("gs://").split("/")[1]
local_file_path = f"{UPLOAD_DIR}/{filename}"
blob = self.bucket.get_blob(filename)
blob.download_to_filename(local_file_path)
return local_file_path
except NotFound as e:
raise RuntimeError(f"Error downloading file from GCS: {e}")
def delete_file(self, file_path: str) -> None:
"""Handles deletion of the file from GCS storage."""
try:
filename = file_path.removeprefix("gs://").split("/")[1]
blob = self.bucket.get_blob(filename)
blob.delete()
except NotFound as e:
raise RuntimeError(f"Error deleting file from GCS: {e}")
# Always delete from local storage
LocalStorageProvider.delete_file(file_path)
def delete_all_files(self) -> None:
"""Handles deletion of all files from GCS storage."""
try:
blobs = self.bucket.list_blobs()
for blob in blobs:
blob.delete()
except NotFound as e:
raise RuntimeError(f"Error deleting all files from GCS: {e}")
# Always delete from local storage
LocalStorageProvider.delete_all_files()
def get_storage_provider(storage_provider: str):
if storage_provider == "local":
Storage = LocalStorageProvider()
elif storage_provider == "s3":
Storage = S3StorageProvider()
elif storage_provider == "gcs":
Storage = GCSStorageProvider()
else:
raise RuntimeError(f"Unsupported storage provider: {storage_provider}")
return Storage
Storage = get_storage_provider(STORAGE_PROVIDER)
@@ -0,0 +1,274 @@
import io
import os
import boto3
import pytest
from botocore.exceptions import ClientError
from moto import mock_aws
from open_webui.storage import provider
from gcp_storage_emulator.server import create_server
from google.cloud import storage
def mock_upload_dir(monkeypatch, tmp_path):
"""Fixture to monkey-patch the UPLOAD_DIR and create a temporary directory."""
directory = tmp_path / "uploads"
directory.mkdir()
monkeypatch.setattr(provider, "UPLOAD_DIR", str(directory))
return directory
def test_imports():
provider.StorageProvider
provider.LocalStorageProvider
provider.S3StorageProvider
provider.GCSStorageProvider
provider.Storage
def test_get_storage_provider():
Storage = provider.get_storage_provider("local")
assert isinstance(Storage, provider.LocalStorageProvider)
Storage = provider.get_storage_provider("s3")
assert isinstance(Storage, provider.S3StorageProvider)
Storage = provider.get_storage_provider("gcs")
assert isinstance(Storage, provider.GCSStorageProvider)
with pytest.raises(RuntimeError):
provider.get_storage_provider("invalid")
def test_class_instantiation():
with pytest.raises(TypeError):
provider.StorageProvider()
with pytest.raises(TypeError):
class Test(provider.StorageProvider):
pass
Test()
provider.LocalStorageProvider()
provider.S3StorageProvider()
provider.GCSStorageProvider()
class TestLocalStorageProvider:
Storage = provider.LocalStorageProvider()
file_content = b"test content"
file_bytesio = io.BytesIO(file_content)
filename = "test.txt"
filename_extra = "test_exyta.txt"
file_bytesio_empty = io.BytesIO()
def test_upload_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, file_path = self.Storage.upload_file(self.file_bytesio, self.filename)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert file_path == str(upload_dir / self.filename)
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
file_path = str(upload_dir / self.filename)
file_path_return = self.Storage.get_file(file_path)
assert file_path == file_path_return
def test_delete_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
(upload_dir / self.filename).write_bytes(self.file_content)
assert (upload_dir / self.filename).exists()
file_path = str(upload_dir / self.filename)
self.Storage.delete_file(file_path)
assert not (upload_dir / self.filename).exists()
def test_delete_all_files(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
(upload_dir / self.filename).write_bytes(self.file_content)
(upload_dir / self.filename_extra).write_bytes(self.file_content)
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
@mock_aws
class TestS3StorageProvider:
def __init__(self):
self.Storage = provider.S3StorageProvider()
self.Storage.bucket_name = "my-bucket"
self.s3_client = boto3.resource("s3", region_name="us-east-1")
self.file_content = b"test content"
self.filename = "test.txt"
self.filename_extra = "test_exyta.txt"
self.file_bytesio_empty = io.BytesIO()
super().__init__()
def test_upload_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# S3 checks
with pytest.raises(Exception):
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
assert self.file_content == object.get()["Body"].read()
# local checks
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert s3_file_path == "s3://" + self.Storage.bucket_name + "/" + self.filename
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
file_path = self.Storage.get_file(s3_file_path)
assert file_path == str(upload_dir / self.filename)
assert (upload_dir / self.filename).exists()
def test_delete_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
assert (upload_dir / self.filename).exists()
self.Storage.delete_file(s3_file_path)
assert not (upload_dir / self.filename).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
def test_delete_all_files(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# create 2 files
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
assert self.file_content == object.get()["Body"].read()
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename_extra)
assert self.file_content == object.get()["Body"].read()
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
assert not (upload_dir / self.filename_extra).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename_extra).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
class TestGCSStorageProvider:
Storage = provider.GCSStorageProvider()
Storage.bucket_name = "my-bucket"
file_content = b"test content"
filename = "test.txt"
filename_extra = "test_exyta.txt"
file_bytesio_empty = io.BytesIO()
@pytest.fixture(scope="class")
def setup(self):
host, port = "localhost", 9023
server = create_server(host, port, in_memory=True)
server.start()
os.environ["STORAGE_EMULATOR_HOST"] = f"http://{host}:{port}"
gcs_client = storage.Client()
bucket = gcs_client.bucket(self.Storage.bucket_name)
bucket.create()
self.Storage.gcs_client, self.Storage.bucket = gcs_client, bucket
yield
bucket.delete(force=True)
server.stop()
def test_upload_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# catch error if bucket does not exist
with pytest.raises(Exception):
self.Storage.bucket = monkeypatch(self.Storage, "bucket", None)
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
object = self.Storage.bucket.get_blob(self.filename)
assert self.file_content == object.download_as_bytes()
# local checks
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert gcs_file_path == "gs://" + self.Storage.bucket_name + "/" + self.filename
# test error if file is empty
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
file_path = self.Storage.get_file(gcs_file_path)
assert file_path == str(upload_dir / self.filename)
assert (upload_dir / self.filename).exists()
def test_delete_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
# ensure that local directory has the uploaded file as well
assert (upload_dir / self.filename).exists()
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
self.Storage.delete_file(gcs_file_path)
# check that deleting file from gcs will delete the local file as well
assert not (upload_dir / self.filename).exists()
assert self.Storage.bucket.get_blob(self.filename) == None
def test_delete_all_files(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# create 2 files
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
object = self.Storage.bucket.get_blob(self.filename)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
assert self.file_content == object.download_as_bytes()
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
object = self.Storage.bucket.get_blob(self.filename_extra)
assert (upload_dir / self.filename_extra).exists()
assert (upload_dir / self.filename_extra).read_bytes() == self.file_content
assert (
self.Storage.bucket.get_blob(self.filename_extra).name
== self.filename_extra
)
assert self.file_content == object.download_as_bytes()
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
assert self.Storage.bucket.get_blob(self.filename) == None
assert self.Storage.bucket.get_blob(self.filename_extra) == None
+37 -7
View File
@@ -1,9 +1,30 @@
from typing import Optional, Union, List, Dict, Any
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
from open_webui.config import DEFAULT_USER_PERMISSIONS
import json
def fill_missing_permissions(
permissions: Dict[str, Any], default_permissions: Dict[str, Any]
) -> Dict[str, Any]:
"""
Recursively fills in missing properties in the permissions dictionary
using the default permissions as a template.
"""
for key, value in default_permissions.items():
if key not in permissions:
permissions[key] = value
elif isinstance(value, dict) and isinstance(
permissions[key], dict
): # Both are nested dictionaries
permissions[key] = fill_missing_permissions(permissions[key], value)
return permissions
def get_permissions(
user_id: str,
default_permissions: Dict[str, Any],
@@ -27,39 +48,45 @@ def get_permissions(
if key not in permissions:
permissions[key] = value
else:
permissions[key] = permissions[key] or value
permissions[key] = (
permissions[key] or value
) # Use the most permissive value (True > False)
return permissions
user_groups = Groups.get_groups_by_member_id(user_id)
# deep copy default permissions to avoid modifying the original dict
# Deep copy default permissions to avoid modifying the original dict
permissions = json.loads(json.dumps(default_permissions))
# Combine permissions from all user groups
for group in user_groups:
group_permissions = group.permissions
permissions = combine_permissions(permissions, group_permissions)
# Ensure all fields from default_permissions are present and filled in
permissions = fill_missing_permissions(permissions, default_permissions)
return permissions
def has_permission(
user_id: str,
permission_key: str,
default_permissions: Dict[str, bool] = {},
default_permissions: Dict[str, Any] = {},
) -> bool:
"""
Check if a user has a specific permission by checking the group permissions
and falls back to default permissions if not found in any group.
and fall back to default permissions if not found in any group.
Permission keys can be hierarchical and separated by dots ('.').
"""
def get_permission(permissions: Dict[str, bool], keys: List[str]) -> bool:
def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool:
"""Traverse permissions dict using a list of keys (from dot-split permission_key)."""
for key in keys:
if key not in permissions:
return False # If any part of the hierarchy is missing, deny access
permissions = permissions[key] # Go one level deeper
permissions = permissions[key] # Traverse one level deeper
return bool(permissions) # Return the boolean at the final level
@@ -73,7 +100,10 @@ def has_permission(
if get_permission(group_permissions, permission_hierarchy):
return True
# Check default permissions afterwards if the group permissions don't allow it
# Check default permissions afterward if the group permissions don't allow it
default_permissions = fill_missing_permissions(
default_permissions, DEFAULT_USER_PERMISSIONS
)
return get_permission(default_permissions, permission_hierarchy)
+12 -5
View File
@@ -96,11 +96,18 @@ def get_current_user(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
allowed_paths = ["/api/models", "/api/chat/completions"]
if request.url.path not in allowed_paths:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
if request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip()
for path in str(
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS
).split(",")
]
if request.url.path not in allowed_paths:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
return get_current_user_by_api_key(token)
+7
View File
@@ -157,6 +157,9 @@ async def generate_chat_completion(
)
chat_completion = generate_chat_completion
async def chat_completed(request: Request, form_data: dict, user: Any):
if not request.app.state.MODELS:
await get_all_models(request)
@@ -179,6 +182,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
@@ -187,6 +191,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
@@ -310,6 +315,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
__event_call__ = get_event_call(
@@ -317,6 +323,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
+437 -148
View File
@@ -23,14 +23,18 @@ from open_webui.models.users import Users
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
get_user_id_from_session_pool,
get_active_status_by_user_id,
)
from open_webui.routers.tasks import (
generate_queries,
generate_title,
generate_image_prompt,
generate_chat_tags,
)
from open_webui.routers.retrieval import process_web_search, SearchForm
from open_webui.routers.images import image_generations, GenerateImageForm
from open_webui.utils.webhook import post_webhook
@@ -65,6 +69,7 @@ from open_webui.env import (
SRC_LOG_LEVELS,
GLOBAL_LOG_LEVEL,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE,
)
from open_webui.constants import TASKS
@@ -485,6 +490,100 @@ async def chat_web_search_handler(
return form_data
async def chat_image_generation_handler(
request: Request, form_data: dict, extra_params: dict, user
):
__event_emitter__ = extra_params["__event_emitter__"]
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generating an image", "done": False},
}
)
messages = form_data["messages"]
user_message = get_last_user_message(messages)
prompt = user_message
negative_prompt = ""
if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
try:
res = await generate_image_prompt(
request,
{
"model": form_data["model"],
"messages": messages,
},
user,
)
response = res["choices"][0]["message"]["content"]
try:
bracket_start = response.find("{")
bracket_end = response.rfind("}") + 1
if bracket_start == -1 or bracket_end == -1:
raise Exception("No JSON object found in the response")
response = response[bracket_start:bracket_end]
response = json.loads(response)
prompt = response.get("prompt", [])
except Exception as e:
prompt = user_message
except Exception as e:
log.exception(e)
prompt = user_message
system_message_content = ""
try:
images = await image_generations(
request=request,
form_data=GenerateImageForm(**{"prompt": prompt}),
user=user,
)
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generated an image", "done": True},
}
)
for image in images:
await __event_emitter__(
{
"type": "message",
"data": {"content": f"![Generated Image]({image['url']})"},
}
)
system_message_content = "<context>User is shown the generated image, tell the user that the image has been generated</context>"
except Exception as e:
log.exception(e)
await __event_emitter__(
{
"type": "status",
"data": {
"description": f"An error occured while generating an image",
"done": True,
},
}
)
system_message_content = "<context>Unable to generate an image, tell the user that an error occured</context>"
if system_message_content:
form_data["messages"] = add_or_update_system_message(
system_message_content, form_data["messages"]
)
return form_data
async def chat_completion_files_handler(
request: Request, body: dict, user: UserModel
) -> tuple[dict, dict[str, list]]:
@@ -493,6 +592,7 @@ async def chat_completion_files_handler(
if files := body.get("metadata", {}).get("files", None):
try:
queries_response = await generate_queries(
request,
{
"model": body["model"],
"messages": body["messages"],
@@ -521,17 +621,28 @@ async def chat_completion_files_handler(
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
sources = get_sources_from_files(
files=files,
queries=queries,
embedding_function=request.app.state.EMBEDDING_FUNCTION,
k=request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
)
try:
# Offload get_sources_from_files to a separate thread
loop = asyncio.get_running_loop()
with ThreadPoolExecutor() as executor:
sources = await loop.run_in_executor(
executor,
lambda: get_sources_from_files(
files=files,
queries=queries,
embedding_function=request.app.state.EMBEDDING_FUNCTION,
k=request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
),
)
except Exception as e:
log.exception(e)
log.debug(f"rag_contexts:sources: {sources}")
return body, {"sources": sources}
@@ -560,7 +671,6 @@ def apply_params_to_form_data(form_data, model):
if "frequency_penalty" in params:
form_data["frequency_penalty"] = params["frequency_penalty"]
return form_data
@@ -639,12 +749,17 @@ async def process_chat_payload(request, form_data, metadata, user, model):
request, form_data, extra_params, user
)
if "image_generation" in features and features["image_generation"]:
form_data = await chat_image_generation_handler(
request, form_data, extra_params, user
)
try:
form_data, flags = await chat_completion_filter_functions_handler(
request, form_data, model, extra_params
)
except Exception as e:
return Exception(f"Error: {e}")
raise Exception(f"Error: {e}")
tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None)
@@ -748,14 +863,101 @@ async def process_chat_payload(request, form_data, metadata, user, model):
async def process_chat_response(
request, response, form_data, user, events, metadata, tasks
):
if not isinstance(response, StreamingResponse):
return response
async def background_tasks_handler():
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
message = message_map.get(metadata["message_id"]) if message_map else None
if not any(
content_type in response.headers["Content-Type"]
for content_type in ["text/event-stream", "application/x-ndjson"]
):
return response
if message:
messages = get_message_list(message_map, message.get("id"))
if tasks:
if TASKS.TITLE_GENERATION in tasks:
if tasks[TASKS.TITLE_GENERATION]:
res = await generate_title(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1:
title = (
res.get("choices", [])[0]
.get("message", {})
.get(
"content",
message.get("content", "New Chat"),
)
).strip()
else:
title = None
if not title:
title = messages[0].get("content", "New Chat")
Chats.update_chat_title_by_id(metadata["chat_id"], title)
await event_emitter(
{
"type": "chat:title",
"data": title,
}
)
elif len(messages) == 2:
title = messages[0].get("content", "New Chat")
Chats.update_chat_title_by_id(metadata["chat_id"], title)
await event_emitter(
{
"type": "chat:title",
"data": message.get("content", "New Chat"),
}
)
if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]:
res = await generate_chat_tags(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1:
tags_string = (
res.get("choices", [])[0]
.get("message", {})
.get("content", "")
)
else:
tags_string = ""
tags_string = tags_string[
tags_string.find("{") : tags_string.rfind("}") + 1
]
try:
tags = json.loads(tags_string).get("tags", [])
Chats.update_chat_tags_by_id(
metadata["chat_id"], tags, user
)
await event_emitter(
{
"type": "chat:tags",
"data": tags,
}
)
except Exception as e:
pass
event_emitter = None
if (
@@ -768,12 +970,90 @@ async def process_chat_response(
):
event_emitter = get_event_emitter(metadata)
if not isinstance(response, StreamingResponse):
if event_emitter:
if "selected_model_id" in response:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response["selected_model_id"],
},
)
if response.get("choices", [])[0].get("message", {}).get("content"):
content = response["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response,
}
)
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
},
}
)
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": content,
},
)
# Send a webhook notification if the user is not active
if get_active_status_by_user_id(user.id) is None:
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
await background_tasks_handler()
return response
else:
return response
if not any(
content_type in response.headers["Content-Type"]
for content_type in ["text/event-stream", "application/x-ndjson"]
):
return response
if event_emitter:
task_id = str(uuid4()) # Create a unique task ID.
# Handle as a background task
async def post_response_handler(response, events):
message = Chats.get_message_by_id_and_message_id(
metadata["chat_id"], metadata["message_id"]
)
content = message.get("content", "") if message else ""
try:
for event in events:
await event_emitter(
@@ -792,8 +1072,15 @@ async def process_chat_response(
},
)
assistant_message = get_last_assistant_message(form_data["messages"])
content = assistant_message if assistant_message else ""
# We might want to disable this by default
detect_reasoning = True
reasoning_tags = ["think", "reason", "reasoning", "thought"]
current_tag = None
reasoning_start_time = None
reasoning_content = ""
ongoing_content = ""
async for line in response.body_iterator:
line = line.decode("utf-8") if isinstance(line, bytes) else line
@@ -803,12 +1090,12 @@ async def process_chat_response(
if not data.strip():
continue
# "data: " is the prefix for each event
if not data.startswith("data: "):
# "data:" is the prefix for each event
if not data.startswith("data:"):
continue
# Remove the prefix
data = data[len("data: ") :]
data = data[len("data:") :].strip()
try:
data = json.loads(data)
@@ -821,9 +1108,7 @@ async def process_chat_response(
"selectedModelId": data["selected_model_id"],
},
)
else:
value = (
data.get("choices", [])[0]
.get("delta", {})
@@ -833,147 +1118,150 @@ async def process_chat_response(
if value:
content = f"{content}{value}"
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": content,
},
)
if detect_reasoning:
for tag in reasoning_tags:
start_tag = f"<{tag}>\n"
end_tag = f"</{tag}>\n"
except Exception as e:
done = "data: [DONE]" in line
title = Chats.get_chat_title_by_id(metadata["chat_id"])
if start_tag in content:
# Remove the start tag
content = content.replace(start_tag, "")
ongoing_content = content
if done:
data = {"done": True, "content": content, "title": title}
reasoning_start_time = time.time()
reasoning_content = ""
# Send a webhook notification if the user is not active
if (
get_user_id_from_session_pool(metadata["session_id"])
is None
):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
current_tag = tag
break
if reasoning_start_time is not None:
# Remove the last value from the content
content = content[: -len(value)]
reasoning_content += value
end_tag = f"</{current_tag}>\n"
if end_tag in reasoning_content:
reasoning_end_time = time.time()
reasoning_duration = int(
reasoning_end_time
- reasoning_start_time
)
reasoning_content = (
reasoning_content.strip(
f"<{current_tag}>\n"
)
.strip(end_tag)
.strip()
)
if reasoning_content:
reasoning_display_content = "\n".join(
(
f"> {line}"
if not line.startswith(">")
else line
)
for line in reasoning_content.splitlines()
)
# Format reasoning with <details> tag
content = f'{ongoing_content}<details type="reasoning" done="true">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
else:
content = ""
reasoning_start_time = None
else:
reasoning_display_content = "\n".join(
(
f"> {line}"
if not line.startswith(">")
else line
)
for line in reasoning_content.splitlines()
)
# Show ongoing thought process
content = f'{ongoing_content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
"content": content,
},
)
else:
data = {
"content": content,
}
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
except Exception as e:
done = "data: [DONE]" in line
if done:
pass
else:
continue
await event_emitter(
title = Chats.get_chat_title_by_id(metadata["chat_id"])
data = {"done": True, "content": content, "title": title}
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"type": "chat:completion",
"data": data,
}
"content": content,
},
)
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
message = message_map.get(metadata["message_id"])
# Send a webhook notification if the user is not active
if get_active_status_by_user_id(user.id) is None:
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
if message:
messages = get_message_list(message_map, message.get("id"))
if tasks:
if TASKS.TITLE_GENERATION in tasks:
if tasks[TASKS.TITLE_GENERATION]:
res = await generate_title(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
title = (
res.get("choices", [])[0]
.get("message", {})
.get(
"content",
message.get("content", "New Chat"),
)
)
Chats.update_chat_title_by_id(
metadata["chat_id"], title
)
await event_emitter(
{
"type": "chat:title",
"data": title,
}
)
elif len(messages) == 2:
title = messages[0].get("content", "New Chat")
Chats.update_chat_title_by_id(
metadata["chat_id"], title
)
await event_emitter(
{
"type": "chat:title",
"data": message.get("content", "New Chat"),
}
)
if (
TASKS.TAGS_GENERATION in tasks
and tasks[TASKS.TAGS_GENERATION]
):
res = await generate_chat_tags(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
tags_string = (
res.get("choices", [])[0]
.get("message", {})
.get("content", "")
)
tags_string = tags_string[
tags_string.find("{") : tags_string.rfind("}") + 1
]
try:
tags = json.loads(tags_string).get("tags", [])
Chats.update_chat_tags_by_id(
metadata["chat_id"], tags, user
)
await event_emitter(
{
"type": "chat:tags",
"data": tags,
}
)
except Exception as e:
print(f"Error: {e}")
await event_emitter(
{
"type": "chat:completion",
"data": data,
}
)
await background_tasks_handler()
except asyncio.CancelledError:
print("Task was cancelled!")
await event_emitter({"type": "task-cancelled"})
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"content": content,
},
)
if response.background is not None:
await response.background()
@@ -997,4 +1285,5 @@ async def process_chat_response(
return StreamingResponse(
stream_wrapper(response.body_iterator, events),
headers=dict(response.headers),
background=response.background,
)
+18 -21
View File
@@ -34,7 +34,7 @@ from open_webui.config import (
JWT_EXPIRES_IN,
AppConfig,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
@@ -63,17 +63,8 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
class OAuthManager:
def __init__(self):
self.oauth = OAuth()
for provider_name, provider_config in OAUTH_PROVIDERS.items():
self.oauth.register(
name=provider_name,
client_id=provider_config["client_id"],
client_secret=provider_config["client_secret"],
server_metadata_url=provider_config["server_metadata_url"],
client_kwargs={
"scope": provider_config["scope"],
},
redirect_uri=provider_config["redirect_uri"],
)
for _, provider_config in OAUTH_PROVIDERS.items():
provider_config["register"](self.oauth)
def get_client(self, provider_name):
return self.oauth.create_client(provider_name)
@@ -200,14 +191,14 @@ class OAuthManager:
except Exception as e:
log.warning(f"OAuth callback error: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
user_data: UserInfo = token["userinfo"]
user_data: UserInfo = token.get("userinfo")
if not user_data:
user_data: UserInfo = await client.userinfo(token=token)
if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
sub = user_data.get("sub")
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@@ -255,12 +246,20 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
picture_url = user_data.get(picture_claim, "")
picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
if picture_url:
# Download the profile image into a base64 string
try:
access_token = token.get("access_token")
get_kwargs = {}
if access_token:
get_kwargs["headers"] = {
"Authorization": f"Bearer {access_token}",
}
async with aiohttp.ClientSession() as session:
async with session.get(picture_url) as resp:
async with session.get(picture_url, **get_kwargs) as resp:
picture = await resp.read()
base64_encoded_picture = base64.b64encode(
picture
@@ -295,12 +294,10 @@ class OAuthManager:
if auth_manager_config.WEBHOOK_URL:
post_webhook(
auth_manager_config.WEBHOOK_URL,
auth_manager_config.WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": auth_manager_config.WEBHOOK_MESSAGES.USER_SIGNUP(
user.name
),
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
@@ -314,7 +311,7 @@ class OAuthManager:
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
self.update_user_groups(
user=user,
user_data=user_data,
+4
View File
@@ -160,6 +160,10 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
# If there are advanced parameters in the payload, format them in Ollama's options field
ollama_options = {}
if openai_payload.get("options"):
ollama_payload["options"] = openai_payload["options"]
ollama_options = openai_payload["options"]
# Handle parameters which map directly
for param in ["temperature", "top_p", "seed"]:
if param in openai_payload:
@@ -53,6 +53,7 @@ class PDFGenerator:
# - https://facelessuser.github.io/pymdown-extensions/usage_notes/
# html_content = markdown(content, extensions=["pymdownx.extra"])
content = content.replace("\n", "<br/>")
html_message = f"""
<div>
<div>
+6 -19
View File
@@ -29,7 +29,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
(
(
data.get("eval_count", 0)
/ ((data.get("eval_duration", 0) / 1_000_000_000))
/ ((data.get("eval_duration", 0) / 10_000_000))
)
* 100
),
@@ -43,12 +43,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
(
(
data.get("prompt_eval_count", 0)
/ (
(
data.get("prompt_eval_duration", 0)
/ 1_000_000_000
)
)
/ ((data.get("prompt_eval_duration", 0) / 10_000_000))
)
* 100
),
@@ -57,20 +52,12 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
if data.get("prompt_eval_duration", 0) > 0
else "N/A"
),
"total_duration": round(
((data.get("total_duration", 0) / 1_000_000) * 100), 2
),
"load_duration": round(
((data.get("load_duration", 0) / 1_000_000) * 100), 2
),
"total_duration": data.get("total_duration", 0),
"load_duration": data.get("load_duration", 0),
"prompt_eval_count": data.get("prompt_eval_count", 0),
"prompt_eval_duration": round(
((data.get("prompt_eval_duration", 0) / 1_000_000) * 100), 2
),
"prompt_eval_duration": data.get("prompt_eval_duration", 0),
"eval_count": data.get("eval_count", 0),
"eval_duration": round(
((data.get("eval_duration", 0) / 1_000_000) * 100), 2
),
"eval_duration": data.get("eval_duration", 0),
"approximate_total": (
lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s"
)((data.get("total_duration", 0) or 0) // 1_000_000_000),
+18
View File
@@ -217,6 +217,24 @@ def tags_generation_template(
return template
def image_prompt_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
return template
def emoji_generation_template(
template: str, prompt: str, user: Optional[dict] = None
) -> str:
+10 -8
View File
@@ -3,7 +3,7 @@ uvicorn[standard]==0.30.6
pydantic==2.9.2
python-multipart==0.0.18
Flask==3.0.3
Flask==3.1.0
Flask-Cors==5.0.0
python-socketio==5.11.3
@@ -18,7 +18,7 @@ aiofiles
sqlalchemy==2.0.32
alembic==1.14.0
peewee==3.17.6
peewee==3.17.8
peewee-migrate==1.12.2
psycopg2-binary==2.9.9
pgvector==0.3.5
@@ -40,14 +40,15 @@ tiktoken
langchain==0.3.7
langchain-community==0.3.7
langchain-chroma==0.1.4
fake-useragent==1.5.1
chromadb==0.5.15
chromadb==0.6.2
pymilvus==2.5.0
qdrant-client~=1.12.0
opensearch-py==2.7.1
transformers
sentence-transformers==3.3.1
colbert-ai==0.2.21
einops==0.8.0
@@ -55,7 +56,7 @@ einops==0.8.0
ftfy==6.2.3
pypdf==4.3.1
fpdf2==2.7.9
fpdf2==2.8.2
pymdown-extensions==10.11.2
docx2txt==0.8
python-pptx==1.0.0
@@ -67,7 +68,7 @@ pandas==2.2.3
openpyxl==3.1.5
pyxlsb==1.0.10
xlrd==2.0.1
validators==0.33.0
validators==0.34.0
psutil
sentencepiece
soundfile==0.12.1
@@ -78,7 +79,7 @@ rank-bm25==0.2.2
faster-whisper==1.0.3
PyJWT[crypto]==2.9.0
PyJWT[crypto]==2.10.1
authlib==1.3.2
black==24.8.0
@@ -88,7 +89,7 @@ pytube==15.0.0
extract_msg
pydub
duckduckgo-search~=6.3.5
duckduckgo-search~=7.2.1
## Google Drive
google-api-python-client
@@ -101,6 +102,7 @@ pytest~=8.3.2
pytest-docker~=3.1.1
googleapis-common-protos==1.63.2
google-cloud-storage==2.19.0
## LDAP
ldap3==2.9.1
+6
View File
@@ -16,6 +16,9 @@ For the UI configuration, you can set up the Apache VirtualHost as follows:
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
</VirtualHost>
```
@@ -32,6 +35,9 @@ Enable the site first before you can request SSL:
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
SSLEngine on
SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert
+19 -10
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.5.0",
"version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.5.0",
"version": "0.5.5",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6",
@@ -16,6 +16,7 @@
"@mediapipe/tasks-vision": "^0.10.17",
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
"@tiptap/core": "^2.10.0",
"@tiptap/extension-code-block-lowlight": "^2.10.0",
"@tiptap/extension-highlight": "^2.10.0",
@@ -40,7 +41,7 @@
"i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"katex": "^0.16.9",
"katex": "^0.16.21",
"marked": "^9.1.0",
"mermaid": "^10.9.3",
"paneforge": "^0.0.6",
@@ -88,7 +89,7 @@
"tailwindcss": "^3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite": "^5.4.14",
"vitest": "^1.6.0"
},
"engines": {
@@ -2291,6 +2292,12 @@
"vite": "^5.0.3 || ^6.0.0"
}
},
"node_modules/@sveltejs/svelte-virtual-list": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/svelte-virtual-list/-/svelte-virtual-list-3.0.1.tgz",
"integrity": "sha512-aF9TptS7NKKS7/TqpsxQBSDJ9Q0XBYzBehCeIC5DzdMEgrJZpIYao9LRLnyyo6SVodpapm2B7FE/Lj+FSA5/SQ==",
"license": "LIL"
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz",
@@ -7103,13 +7110,14 @@
}
},
"node_modules/katex": {
"version": "0.16.10",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz",
"integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==",
"version": "0.16.21",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz",
"integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
@@ -11670,9 +11678,10 @@
}
},
"node_modules/vite": {
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz",
"integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==",
"version": "5.4.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz",
"integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+6 -4
View File
@@ -1,9 +1,10 @@
{
"name": "open-webui",
"version": "0.5.0",
"version": "0.5.5",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
"build": "npm run pyodide:fetch && vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -44,13 +45,12 @@
"tailwindcss": "^3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite": "^5.4.14",
"vitest": "^1.6.0"
},
"type": "module",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"codemirror-lang-hcl": "^0.0.0-beta.2",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language-data": "^6.5.1",
"@codemirror/theme-one-dark": "^6.1.2",
@@ -58,6 +58,7 @@
"@mediapipe/tasks-vision": "^0.10.17",
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
"@tiptap/core": "^2.10.0",
"@tiptap/extension-code-block-lowlight": "^2.10.0",
"@tiptap/extension-highlight": "^2.10.0",
@@ -69,6 +70,7 @@
"async": "^3.2.5",
"bits-ui": "^0.19.7",
"codemirror": "^6.0.1",
"codemirror-lang-hcl": "^0.0.0-beta.2",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",
@@ -81,7 +83,7 @@
"i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"katex": "^0.16.9",
"katex": "^0.16.21",
"marked": "^9.1.0",
"mermaid": "^10.9.3",
"paneforge": "^0.0.6",
+23 -9
View File
@@ -11,7 +11,7 @@ dependencies = [
"pydantic==2.9.2",
"python-multipart==0.0.18",
"Flask==3.0.3",
"Flask==3.1.0",
"Flask-Cors==5.0.0",
"python-socketio==5.11.3",
@@ -26,7 +26,7 @@ dependencies = [
"sqlalchemy==2.0.32",
"alembic==1.14.0",
"peewee==3.17.6",
"peewee==3.17.8",
"peewee-migrate==1.12.2",
"psycopg2-binary==2.9.9",
"pgvector==0.3.5",
@@ -47,21 +47,21 @@ dependencies = [
"langchain==0.3.7",
"langchain-community==0.3.7",
"langchain-chroma==0.1.4",
"fake-useragent==1.5.1",
"chromadb==0.5.15",
"chromadb==0.6.2",
"pymilvus==2.5.0",
"qdrant-client~=1.12.0",
"opensearch-py==2.7.1",
"transformers",
"sentence-transformers==3.3.1",
"colbert-ai==0.2.21",
"einops==0.8.0",
"ftfy==6.2.3",
"pypdf==4.3.1",
"fpdf2==2.7.9",
"fpdf2==2.8.2",
"pymdown-extensions==10.11.2",
"docx2txt==0.8",
"python-pptx==1.0.0",
@@ -73,7 +73,7 @@ dependencies = [
"openpyxl==3.1.5",
"pyxlsb==1.0.10",
"xlrd==2.0.1",
"validators==0.33.0",
"validators==0.34.0",
"psutil",
"sentencepiece",
"soundfile==0.12.1",
@@ -84,7 +84,7 @@ dependencies = [
"faster-whisper==1.0.3",
"PyJWT[crypto]==2.9.0",
"PyJWT[crypto]==2.10.1",
"authlib==1.3.2",
"black==24.8.0",
@@ -94,15 +94,22 @@ dependencies = [
"extract_msg",
"pydub",
"duckduckgo-search~=6.3.5",
"duckduckgo-search~=7.2.1",
"google-api-python-client",
"google-auth-httplib2",
"google-auth-oauthlib",
"docker~=7.1.0",
"pytest~=8.3.2",
"pytest-docker~=3.1.1",
"moto[s3]>=5.0.26",
"googleapis-common-protos==1.63.2",
"google-cloud-storage==2.19.0",
"ldap3==2.9.1"
"ldap3==2.9.1",
"gcp-storage-emulator>=2024.8.3",
]
readme = "README.md"
requires-python = ">= 3.11, < 3.13.0a1"
@@ -151,3 +158,10 @@ exclude = [
"chroma.sqlite3",
]
force-include = { "CHANGELOG.md" = "open_webui/CHANGELOG.md", build = "open_webui/frontend" }
[tool.codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = '.git*,*.svg,package-lock.json,i18n,*.lock,*.css,*-bundle.js,locales,example-doc.txt,emoji-shortcodes.json'
check-hidden = true
# ignore-regex = ''
ignore-words-list = 'ans'
+61 -4
View File
@@ -53,11 +53,11 @@ math {
}
.markdown-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply text-xs prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown a {
@@ -68,6 +68,19 @@ math {
font-family: 'Archivo', sans-serif;
}
.drag-region {
-webkit-app-region: drag;
}
.drag-region a,
.drag-region button {
-webkit-app-region: no-drag;
}
.no-drag-region {
-webkit-app-region: no-drag;
}
iframe {
@apply rounded-lg;
}
@@ -102,18 +115,62 @@ li p {
select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-position: right 0rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
/* padding-right: 2.5rem; */
/* for Firefox */
-moz-appearance: none;
/* for Chrome */
-webkit-appearance: none;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.shimmer {
background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #818286; /* Fallback color */
}
:global(.dark) .shimmer {
background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #a1a3a7; /* Darker fallback color for dark mode */
}
@keyframes smoothFadeIn {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.status-description {
animation: smoothFadeIn 0.2s forwards;
}
.katex-mathml {
display: none;
}
+122
View File
@@ -1,4 +1,5 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { t } from 'i18next';
type ChannelForm = {
name: string;
@@ -207,7 +208,48 @@ export const getChannelMessages = async (
return res;
};
export const getChannelThreadMessages = async (
token: string = '',
channel_id: string,
message_id: string,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type MessageForm = {
parent_id?: string;
content: string;
data?: object;
meta?: object;
@@ -285,6 +327,86 @@ export const updateMessage = async (
return res;
};
export const addReaction = async (
token: string = '',
channel_id: string,
message_id: string,
name: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/add`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ name })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const removeReaction = async (
token: string = '',
channel_id: string,
message_id: string,
name: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/remove`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ name })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteMessage = async (token: string = '', channel_id: string, message_id: string) => {
let error = null;
+38
View File
@@ -618,6 +618,44 @@ export const cloneChatById = async (token: string, id: string) => {
return res;
};
export const cloneSharedChatById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone/shared`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const shareChatById = async (token: string, id: string) => {
let error = null;
+1 -1
View File
@@ -682,7 +682,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => {
searchParams.append('urlIdx', urlIdx);
}
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines?${searchParams.toString()}`, {
const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines/?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
+16 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
@@ -11,8 +12,21 @@
export let content: string;
onMount(() => {
const audio = new Audio(`/audio/notification.mp3`);
audio.play();
if (!navigator.userActivation.hasBeenActive) {
return;
}
if ($settings?.notificationSound ?? true) {
if (!$playingNotificationSound && $isLastActiveTab) {
playingNotificationSound.set(true);
const audio = new Audio(`/audio/notification.mp3`);
audio.play().finally(() => {
// Ensure the global state is reset after the sound finishes
playingNotificationSound.set(false);
});
}
}
});
</script>
+7 -7
View File
@@ -61,7 +61,7 @@
const shareHandler = async (func) => {
const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -88,7 +88,7 @@
const cloneHandler = async (func) => {
const _function = await getFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -104,7 +104,7 @@
const exportHandler = async (func) => {
const _function = await getFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -118,7 +118,7 @@
const deleteHandler = async (func) => {
const res = await deleteFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -132,7 +132,7 @@
const toggleGlobalHandler = async (func) => {
const res = await toggleGlobalById(localStorage.token, func.id).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -418,7 +418,7 @@
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -510,7 +510,7 @@
for (const func of _functions) {
const res = await createNewFunction(localStorage.token, func).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
}
@@ -43,9 +43,8 @@
const updateOpenAIHandler = async () => {
if (ENABLE_OPENAI_API !== null) {
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
(url, urlIdx) => OPENAI_API_BASE_URLS.indexOf(url) === urlIdx && url !== ''
).map((url) => url.replace(/\/$/, ''));
// Remove trailing slashes
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
// Check if API KEYS length is same than API URLS length
if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
@@ -69,7 +68,7 @@
OPENAI_API_KEYS: OPENAI_API_KEYS,
OPENAI_API_CONFIGS: OPENAI_API_CONFIGS
}).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -81,24 +80,15 @@
const updateOllamaHandler = async () => {
if (ENABLE_OLLAMA_API !== null) {
// Remove duplicate URLs
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter(
(url, urlIdx) => OLLAMA_BASE_URLS.indexOf(url) === urlIdx && url !== ''
).map((url) => url.replace(/\/$/, ''));
console.log(OLLAMA_BASE_URLS);
if (OLLAMA_BASE_URLS.length === 0) {
ENABLE_OLLAMA_API = false;
toast.info($i18n.t('Ollama API disabled'));
}
// Remove trailing slashes
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.map((url) => url.replace(/\/$/, ''));
const res = await updateOllamaConfig(localStorage.token, {
ENABLE_OLLAMA_API: ENABLE_OLLAMA_API,
OLLAMA_BASE_URLS: OLLAMA_BASE_URLS,
OLLAMA_API_CONFIGS: OLLAMA_API_CONFIGS
}).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -111,14 +101,14 @@
const addOpenAIConnectionHandler = async (connection) => {
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url];
OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key];
OPENAI_API_CONFIGS[connection.url] = connection.config;
OPENAI_API_CONFIGS[OPENAI_API_BASE_URLS.length] = connection.config;
await updateOpenAIHandler();
};
const addOllamaConnectionHandler = async (connection) => {
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, connection.url];
OLLAMA_API_CONFIGS[connection.url] = connection.config;
OLLAMA_API_CONFIGS[OLLAMA_BASE_URLS.length] = connection.config;
await updateOllamaHandler();
};
@@ -148,15 +138,17 @@
OLLAMA_API_CONFIGS = ollamaConfig.OLLAMA_API_CONFIGS;
if (ENABLE_OPENAI_API) {
for (const url of OPENAI_API_BASE_URLS) {
if (!OPENAI_API_CONFIGS[url]) {
OPENAI_API_CONFIGS[url] = {};
// get url and idx
for (const [idx, url] of OPENAI_API_BASE_URLS.entries()) {
if (!OPENAI_API_CONFIGS[idx]) {
// Legacy support, url as key
OPENAI_API_CONFIGS[idx] = OPENAI_API_CONFIGS[url] || {};
}
}
OPENAI_API_BASE_URLS.forEach(async (url, idx) => {
OPENAI_API_CONFIGS[url] = OPENAI_API_CONFIGS[url] || {};
if (!(OPENAI_API_CONFIGS[url]?.enable ?? true)) {
OPENAI_API_CONFIGS[idx] = OPENAI_API_CONFIGS[idx] || {};
if (!(OPENAI_API_CONFIGS[idx]?.enable ?? true)) {
return;
}
const res = await getOpenAIModels(localStorage.token, idx);
@@ -167,9 +159,9 @@
}
if (ENABLE_OLLAMA_API) {
for (const url of OLLAMA_BASE_URLS) {
if (!OLLAMA_API_CONFIGS[url]) {
OLLAMA_API_CONFIGS[url] = {};
for (const [idx, url] of OLLAMA_BASE_URLS.entries()) {
if (!OLLAMA_API_CONFIGS[idx]) {
OLLAMA_API_CONFIGS[idx] = OLLAMA_API_CONFIGS[url] || {};
}
}
}
@@ -242,7 +234,7 @@
pipeline={pipelineUrls[url] ? true : false}
bind:url
bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[url]}
bind:config={OPENAI_API_CONFIGS[idx]}
onSubmit={() => {
updateOpenAIHandler();
}}
@@ -251,6 +243,8 @@
(url, urlIdx) => idx !== urlIdx
);
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
delete OPENAI_API_CONFIGS[idx];
}}
/>
{/each}
@@ -301,13 +295,14 @@
{#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection
bind:url
bind:config={OLLAMA_API_CONFIGS[url]}
bind:config={OLLAMA_API_CONFIGS[idx]}
{idx}
onSubmit={() => {
updateOllamaHandler();
}}
onDelete={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
delete OLLAMA_API_CONFIGS[idx];
}}
/>
{/each}
@@ -37,7 +37,7 @@
const verifyOllamaHandler = async () => {
const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -47,7 +47,7 @@
const verifyOpenAIHandler = async () => {
const res = await verifyOpenAIConnection(localStorage.token, url, key).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import ManageOllamaModal from './ManageOllamaModal.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
export let onDelete = () => {};
export let onSubmit = () => {};
@@ -70,7 +71,7 @@
}}
type="button"
>
<Wrench />
<ArrowDownTray />
</button>
</Tooltip>
@@ -47,7 +47,7 @@
reader.onload = async (e) => {
const res = await importConfig(localStorage.token, JSON.parse(e.target.result)).catch(
(error) => {
toast.error(error);
toast.error(`${error}`);
}
);
@@ -132,7 +132,7 @@
// exportAllUserChats();
downloadDatabase(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
}}
>
@@ -119,7 +119,7 @@
url: OpenAIUrl
}
}).catch(async (error) => {
toast.error(error);
toast.error(`${error}`);
await setEmbeddingConfig();
return null;
});
@@ -142,7 +142,7 @@
const res = await updateRerankingConfig(localStorage.token, {
reranking_model: rerankingModel
}).catch(async (error) => {
toast.error(error);
toast.error(`${error}`);
await setRerankingConfig();
return null;
});
@@ -258,7 +258,7 @@
bind:show={showResetUploadDirConfirm}
on:confirm={async () => {
const res = await deleteAllFiles(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -272,7 +272,7 @@
bind:show={showResetConfirm}
on:confirm={() => {
const res = resetVectorDB(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -28,6 +28,7 @@
label: '',
host: '',
port: '',
attribute_for_mail: 'mail',
attribute_for_username: 'uid',
app_dn: '',
app_dn_password: '',
@@ -41,7 +42,7 @@
const updateLdapServerHandler = async () => {
if (!ENABLE_LDAP) return;
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
if (res) {
@@ -112,12 +113,48 @@
</div>
</div>
<div class=" flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key Auth')}</div>
<div class=" flex w-full justify-between pr-2 my-3">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY} />
</div>
{#if adminConfig?.ENABLE_API_KEY}
<div class=" flex w-full justify-between pr-2 my-3">
<div class=" self-center text-xs font-medium">
{$i18n.t('API Key Endpoint Restrictions')}
</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS} />
</div>
{#if adminConfig?.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS}
<div class=" flex w-full flex-col pr-2">
<div class=" text-xs font-medium">
{$i18n.t('Allowed Endpoints')}
</div>
<input
class="w-full mt-1 rounded-lg text-sm dark:text-gray-300 bg-transparent outline-none"
type="text"
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
bind:value={adminConfig.API_KEY_ALLOWED_ENDPOINTS}
/>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<!-- https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints -->
<a
href="https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints"
target="_blank"
class=" text-gray-300 font-medium underline"
>
{$i18n.t('To learn more about available endpoints, visit our documentation.')}
</a>
</div>
</div>
{/if}
{/if}
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div class="my-3 flex w-full items-center justify-between pr-2">
@@ -306,6 +343,26 @@
/>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
{$i18n.t('Attribute for Mail')}
</div>
<Tooltip
content={$i18n.t(
'The LDAP attribute that maps to the mail that users use to sign in.'
)}
placement="top-start"
>
<input
class="w-full bg-transparent outline-none py-0.5"
required
placeholder={$i18n.t('Example: mail')}
bind:value={LDAP_SERVER.attribute_for_mail}
/>
</Tooltip>
</div>
</div>
<div class="flex w-full gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium min-w-fit mb-1">
+21 -12
View File
@@ -99,7 +99,7 @@
const getModels = async () => {
models = await getImageGenerationModels(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
};
@@ -107,11 +107,11 @@
const updateConfigHandler = async () => {
const res = await updateConfig(localStorage.token, config)
.catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
})
.catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -159,13 +159,13 @@
}
await updateConfig(localStorage.token, config).catch((error) => {
toast.error(error);
toast.error(`${error}`);
loading = false;
return null;
});
await updateImageGenerationConfig(localStorage.token, imageGenerationConfig).catch((error) => {
toast.error(error);
toast.error(`${error}`);
loading = false;
return null;
});
@@ -178,7 +178,7 @@
onMount(async () => {
if ($user.role === 'admin') {
const res = await getConfig(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -211,7 +211,7 @@
});
const imageConfigRes = await getImageGenerationConfig(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -234,7 +234,7 @@
<div class=" mb-1 text-sm font-medium">{$i18n.t('Image Settings')}</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div class=" py-1 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Image Generation (Experimental)')}
</div>
@@ -271,11 +271,20 @@
</div>
</div>
<div class=" py-0.5 flex w-full justify-between">
{#if config.enabled}
<div class=" py-1 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Image Prompt Generation')}</div>
<div class="px-1">
<Switch bind:state={config.prompt_generation} />
</div>
</div>
{/if}
<div class=" py-1 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Image Generation Engine')}</div>
<div class="flex items-center relative">
<select
class="w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
bind:value={config.engine}
placeholder={$i18n.t('Select Engine')}
on:change={async () => {
@@ -309,7 +318,7 @@
on:click={async () => {
await updateConfigHandler();
const res = await verifyConfigUrl(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -445,7 +454,7 @@
on:click={async () => {
await updateConfigHandler();
const res = await verifyConfigUrl(localStorage.token).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -24,6 +24,7 @@
TASK_MODEL: '',
TASK_MODEL_EXTERNAL: '',
TITLE_GENERATION_PROMPT_TEMPLATE: '',
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: '',
ENABLE_AUTOCOMPLETE_GENERATION: true,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1,
TAGS_GENERATION_PROMPT_TEMPLATE: '',
@@ -140,6 +141,22 @@
</Tooltip>
</div>
<div class="mt-3">
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Image Prompt Generation Prompt')}</div>
<Tooltip
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
placement="top-start"
>
<Textarea
bind:value={taskConfig.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE}
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
/>
</Tooltip>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
<div class="my-3 flex w-full items-center justify-between">
@@ -26,6 +26,9 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
let importFiles;
let modelsImportInputElement: HTMLInputElement;
@@ -39,6 +42,7 @@
let selectedModelId = null;
let showConfigModal = false;
let showManageModal = false;
$: if (models) {
filteredModels = models
@@ -128,7 +132,7 @@
await toggleModelById(localStorage.token, model.id);
}
await init();
// await init();
_models.set(await getModels(localStorage.token));
};
@@ -138,6 +142,7 @@
</script>
<ConfigureModelsModal bind:show={showConfigModal} initHandler={init} />
<ManageModelsModal bind:show={showManageModal} />
{#if models !== null}
{#if selectedModelId === null}
@@ -151,10 +156,22 @@
>
</div>
<div>
<Tooltip content={$i18n.t('Configure')}>
<div class="flex items-center gap-1.5">
<Tooltip content={$i18n.t('Manage Models')}>
<button
class=" px-2.5 py-1 rounded-full flex gap-1 items-center"
class=" p-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showManageModal = true;
}}
>
<ArrowDownTray />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Settings')}>
<button
class=" p-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showConfigModal = true;
@@ -33,6 +33,24 @@
init();
}
$: if (selectedModelId) {
onModelSelect();
}
const onModelSelect = () => {
if (selectedModelId === '') {
return;
}
if (defaultModelIds.includes(selectedModelId)) {
selectedModelId = '';
return;
}
defaultModelIds = [...defaultModelIds, selectedModelId];
selectedModelId = '';
};
const init = async () => {
config = await getModelsConfig(localStorage.token);
@@ -95,7 +113,7 @@
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Configure Models')}
{$i18n.t('Settings')}
</div>
<button
class="self-center"
@@ -143,6 +161,24 @@
<div class="text-xs text-gray-500">{$i18n.t('Default Models')}</div>
</div>
<div class="flex items-center -mr-1">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</div>
<!-- <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> -->
{#if defaultModelIds.length > 0}
<div class="flex flex-col">
{#each defaultModelIds as modelId, modelIdx}
@@ -170,44 +206,6 @@
{$i18n.t('No models selected')}
</div>
{/if}
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex items-center">
<select
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
bind:value={selectedModelId}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
<div>
<button
type="button"
on:click={() => {
if (selectedModelId === '') {
return;
}
if (defaultModelIds.includes(selectedModelId)) {
return;
}
defaultModelIds = [...defaultModelIds, selectedModelId];
selectedModelId = '';
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,26 @@
<script>
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import ManageOllama from './ManageOllama.svelte';
export let ollamaConfig = null;
let selectedUrlIdx = 0;
</script>
{#if ollamaConfig}
<div class="flex-1 mb-2.5 pr-1.5 rounded-lg bg-gray-50 dark:text-gray-300 dark:bg-gray-850">
<select
class="w-full py-2 px-4 text-sm outline-none bg-transparent"
bind:value={selectedUrlIdx}
placeholder={$i18n.t('Select an Ollama instance')}
>
{#each ollamaConfig.OLLAMA_BASE_URLS as url, idx}
<option value={idx}>{url}</option>
{/each}
</select>
</div>
<ManageOllama urlIdx={selectedUrlIdx} />
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { user } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from './Manage/ManageOllama.svelte';
import { getOllamaConfig } from '$lib/apis/ollama';
import Spinner from '$lib/components/common/Spinner.svelte';
import ManageMultipleOllama from './Manage/ManageMultipleOllama.svelte';
export let show = false;
let selected = null;
let ollamaConfig = null;
onMount(async () => {
if ($user.role === 'admin') {
await Promise.all([
(async () => {
ollamaConfig = await getOllamaConfig(localStorage.token);
})()
]);
if (ollamaConfig) {
selected = 'ollama';
return;
}
selected = '';
}
});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Manage Models')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if selected === ''}
<div class=" py-5 text-gray-400 text-xs">
<div>
{$i18n.t('No inference engine with management support found')}
</div>
</div>
{:else if selected !== null}
<div class=" flex w-full flex-col">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
>
<button
class="min-w-fit rounded-full p-1.5 {selected === 'ollama'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selected = 'ollama';
}}>{$i18n.t('Ollama')}</button
>
<!-- <button
class="min-w-fit rounded-full p-1.5 {selected === 'llamacpp'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selected = 'llamacpp';
}}>{$i18n.t('Llama.cpp')}</button
> -->
</div>
<div class=" px-1.5 py-1">
{#if selected === 'ollama'}
<ManageMultipleOllama {ollamaConfig} />
{/if}
</div>
</div>
{:else}
<div class=" py-5">
<Spinner />
</div>
{/if}
</div>
</div>
</div>
</Modal>
@@ -57,7 +57,7 @@
valves,
selectedPipelinesUrlIdx
).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -118,7 +118,7 @@
pipelineDownloadUrl,
selectedPipelinesUrlIdx
).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -157,7 +157,7 @@
}
pipelineFiles = null;
const pipelineUploadInputElement = document.getElementById('pipeline-upload-input');
const pipelineUploadInputElement = document.getElementById('pipelines-upload-input');
if (pipelineUploadInputElement) {
pipelineUploadInputElement.value = null;
@@ -172,7 +172,7 @@
pipelines[selectedPipelineIdx].id,
selectedPipelinesUrlIdx
).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
+7 -2
View File
@@ -53,10 +53,15 @@
tools: false
},
chat: {
controls: true,
file_upload: true,
delete: true,
edit: true,
temporary: true
},
features: {
web_search: true,
image_generation: true
}
};
@@ -69,7 +74,7 @@
const addGroupHandler = async (group) => {
const res = await createNewGroup(localStorage.token, group).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -84,7 +89,7 @@
const res = await updateUserDefaultPermissions(localStorage.token, group.permissions).catch(
(error) => {
toast.error(error);
toast.error(`${error}`);
return null;
}
);
@@ -37,10 +37,15 @@
tools: false
},
chat: {
controls: true,
file_upload: true,
delete: true,
edit: true,
temporary: true
},
features: {
web_search: true,
image_generation: true
}
};
export let userIds = [];
@@ -65,20 +70,8 @@
if (group) {
name = group.name;
description = group.description;
permissions = group?.permissions ?? {
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
chat: {
file_upload: true,
delete: true,
edit: true,
temporary: true
}
};
permissions = group?.permissions ?? {};
userIds = group?.user_ids ?? [];
}
};
@@ -23,7 +23,7 @@
const updateHandler = async (_group) => {
const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -35,7 +35,7 @@
const deleteHandler = async () => {
const res = await deleteGroupById(localStorage.token, group.id).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -1,11 +1,12 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let permissions = {
// Default values for permissions
const defaultPermissions = {
workspace: {
models: false,
knowledge: false,
@@ -13,12 +14,38 @@
tools: false
},
chat: {
controls: true,
delete: true,
edit: true,
temporary: true,
file_upload: true
},
features: {
web_search: true,
image_generation: true
}
};
export let permissions = {};
// Reactive statement to ensure all fields are present in `permissions`
$: {
permissions = fillMissingProperties(permissions, defaultPermissions);
}
function fillMissingProperties(obj: any, defaults: any) {
return {
...defaults,
...obj,
workspace: { ...defaults.workspace, ...obj.workspace },
chat: { ...defaults.chat, ...obj.chat },
features: { ...defaults.features, ...obj.features }
};
}
onMount(() => {
permissions = fillMissingProperties(permissions, defaultPermissions);
});
</script>
<div>
@@ -169,6 +196,14 @@
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Controls')}
</div>
<Switch bind:state={permissions.chat.controls} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')}
@@ -201,4 +236,26 @@
<Switch bind:state={permissions.chat.temporary} />
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Web Search')}
</div>
<Switch bind:state={permissions.features.web_search} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Image Generation')}
</div>
<Switch bind:state={permissions.features.image_generation} />
</div>
</div>
</div>
@@ -44,7 +44,7 @@
const updateRoleHandler = async (id, role) => {
const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -55,7 +55,7 @@
const deleteUserHandler = async (id) => {
const res = await deleteUserById(localStorage.token, id).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
if (res) {
@@ -49,7 +49,7 @@
_user.password,
_user.role
).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -114,6 +114,8 @@
toast.error($i18n.t('File not found.'));
}
}
loading = false;
};
</script>
@@ -148,9 +150,13 @@
submitHandler();
}}
>
<div class="flex text-center text-sm font-medium rounded-full bg-transparent/10 p-1 mb-2">
<div
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
>
<button
class="w-full rounded-full p-1.5 {tab === '' ? 'bg-gray-50 dark:bg-gray-850' : ''}"
class="min-w-fit rounded-full p-1.5 {tab === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button"
on:click={() => {
tab = '';
@@ -158,23 +164,24 @@
>
<button
class="w-full rounded-full p-1 {tab === 'import'
? 'bg-gray-50 dark:bg-gray-850'
: ''}"
class="min-w-fit rounded-full p-1.5 {tab === 'import'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
type="button"
on:click={() => {
tab = 'import';
}}>{$i18n.t('CSV Import')}</button
>
</div>
<div class="px-1">
{#if tab === ''}
<div class="flex flex-col w-full">
<div class="flex flex-col w-full mb-3">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
<div class="flex-1">
<select
class="w-full capitalize rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-none"
bind:value={_user.role}
placeholder={$i18n.t('Enter Your Role')}
required
@@ -191,7 +198,7 @@
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="text"
bind:value={_user.name}
placeholder={$i18n.t('Enter Your Full Name')}
@@ -208,7 +215,7 @@
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
@@ -222,7 +229,7 @@
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
type="password"
bind:value={_user.password}
placeholder={$i18n.t('Enter Your Password')}
@@ -24,7 +24,7 @@
const submitHandler = async () => {
const res = await updateUserById(localStorage.token, selectedUser.id, _user).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -20,7 +20,7 @@
const deleteChatHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
chats = await getChatListByUserId(localStorage.token, user.id);
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
let selected = '';
</script>
<div class="min-w-[4.5rem] bg-gray-50 dark:bg-gray-950 flex gap-2.5 flex-col pt-8">
<div class="flex justify-center relative">
{#if selected === 'home'}
<div class="absolute top-0 left-0 flex h-full">
<div class="my-auto rounded-r-lg w-1 h-8 bg-black dark:bg-white"></div>
</div>
{/if}
<Tooltip content="Home" placement="right">
<button
class=" cursor-pointer {selected === 'home' ? 'rounded-2xl' : 'rounded-full'}"
on:click={() => {
selected = 'home';
if (window.electronAPI) {
window.electronAPI.load('home');
}
}}
>
<img
src="/static/splash.png"
class="size-11 dark:invert p-0.5"
alt="logo"
draggable="false"
/>
</button>
</Tooltip>
</div>
<div class=" -mt-1 border-[1.5px] border-gray-100 dark:border-gray-900 mx-4"></div>
<div class="flex justify-center relative group">
{#if selected === ''}
<div class="absolute top-0 left-0 flex h-full">
<div class="my-auto rounded-r-lg w-1 h-8 bg-black dark:bg-white"></div>
</div>
{/if}
<button
class=" cursor-pointer bg-transparent"
on:click={() => {
selected = '';
}}
>
<img
src="/static/favicon.png"
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
alt="logo"
draggable="false"
/>
</button>
</div>
<!-- <div class="flex justify-center relative group text-gray-400">
<button class=" cursor-pointer p-2" on:click={() => {}}>
<Plus className="size-4" strokeWidth="2" />
</button>
</div> -->
</div>
+195 -48
View File
@@ -1,14 +1,19 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onDestroy, onMount, tick } from 'svelte';
import { Pane, PaneGroup, PaneResizer } from 'paneforge';
import { chatId, showSidebar, socket } from '$lib/stores';
import { onDestroy, onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { chatId, showSidebar, socket, user } from '$lib/stores';
import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
import Messages from './Messages.svelte';
import MessageInput from './MessageInput.svelte';
import { goto } from '$app/navigation';
import Navbar from './Navbar.svelte';
import Drawer from '../common/Drawer.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Thread from './Thread.svelte';
export let id = '';
@@ -20,18 +25,29 @@
let channel = null;
let messages = null;
let threadId = null;
let typingUsers = [];
let typingUsersTimeout = {};
$: if (id) {
initHandler();
}
const scrollToBottom = () => {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
};
const initHandler = async () => {
top = false;
messages = null;
channel = null;
threadId = null;
typingUsers = [];
typingUsersTimeout = {};
channel = await getChannelById(localStorage.token, id).catch((error) => {
return null;
@@ -41,7 +57,7 @@
messages = await getChannelMessages(localStorage.token, id, 0);
if (messages) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
scrollToBottom();
if (messages.length < 50) {
top = true;
@@ -53,41 +69,80 @@
};
const channelEventHandler = async (event) => {
console.log(event);
if (event.channel_id === id) {
const type = event?.data?.type ?? null;
const data = event?.data?.data ?? null;
if (type === 'message') {
console.log('message', data);
messages = [data, ...messages];
await tick();
if (scrollEnd) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
if ((data?.parent_id ?? null) === null) {
messages = [data, ...messages];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}
await tick();
if (scrollEnd) {
scrollToBottom();
}
}
} else if (type === 'message:update') {
console.log('message:update', data);
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
} else if (type === 'message:delete') {
console.log('message:delete', data);
messages = messages.filter((message) => message.id !== data.id);
} else if (type === 'message:reply') {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
} else if (type.includes('message:reaction')) {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
} else if (type === 'typing' && event.message_id === null) {
if (event.user.id === $user.id) {
return;
}
typingUsers = data.typing
? [
...typingUsers,
...(typingUsers.find((user) => user.id === event.user.id)
? []
: [
{
id: event.user.id,
name: event.user.name
}
])
]
: typingUsers.filter((user) => user.id !== event.user.id);
if (typingUsersTimeout[event.user.id]) {
clearTimeout(typingUsersTimeout[event.user.id]);
}
typingUsersTimeout[event.user.id] = setTimeout(() => {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}, 5000);
}
}
};
const submitHandler = async ({ content, data }) => {
if (!content) {
if (!content && (data?.files ?? []).length === 0) {
return;
}
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
(error) => {
toast.error(error);
toast.error(`${error}`);
return null;
}
);
@@ -97,59 +152,151 @@
}
};
const onChange = async () => {
$socket?.emit('channel-events', {
channel_id: id,
message_id: null,
data: {
type: 'typing',
data: {
typing: true
}
}
});
};
let mediaQuery;
let largeScreen = false;
onMount(() => {
if ($chatId) {
chatId.set('');
}
$socket?.on('channel-events', channelEventHandler);
mediaQuery = window.matchMedia('(min-width: 1024px)');
const handleMediaQuery = async (e) => {
if (e.matches) {
largeScreen = true;
} else {
largeScreen = false;
}
};
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
});
onDestroy(() => {
// $socket?.off('channel-events', channelEventHandler);
$socket?.off('channel-events', channelEventHandler);
});
</script>
<svelte:head>
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
</svelte:head>
<div
class="h-screen max-h-[100dvh] {$showSidebar
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col"
id="channel-container"
>
<Navbar {channel} />
<PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} minSize={50} class="h-full flex flex-col w-full relative">
<Navbar {channel} />
<div class="flex-1 overflow-y-auto">
{#if channel}
<div
class=" pb-2.5 max-w-full z-10 scrollbar-hidden w-full h-full pt-6 flex-1 flex flex-col-reverse overflow-auto"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
scrollEnd = Math.abs(messagesContainerElement.scrollTop) <= 50;
}}
<div class="flex-1 overflow-y-auto">
{#if channel}
<div
class=" pb-2.5 max-w-full z-10 scrollbar-hidden w-full h-full pt-6 flex-1 flex flex-col-reverse overflow-auto"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
scrollEnd = Math.abs(messagesContainerElement.scrollTop) <= 50;
}}
>
{#key id}
<Messages
{channel}
{messages}
{top}
onThread={(id) => {
threadId = id;
}}
onLoad={async () => {
const newMessages = await getChannelMessages(
localStorage.token,
id,
messages.length
);
messages = [...messages, ...newMessages];
if (newMessages.length < 50) {
top = true;
return;
}
}}
/>
{/key}
</div>
{/if}
</div>
<div class=" pb-[1rem]">
<MessageInput
id="root"
{typingUsers}
{onChange}
onSubmit={submitHandler}
{scrollToBottom}
{scrollEnd}
/>
</div>
</Pane>
{#if !largeScreen}
{#if threadId !== null}
<Drawer
show={threadId !== null}
on:close={() => {
threadId = null;
}}
>
<div class=" {threadId !== null ? ' h-screen w-full' : 'px-6 py-4'} h-full">
<Thread
{threadId}
{channel}
onClose={() => {
threadId = null;
}}
/>
</div>
</Drawer>
{/if}
{:else if threadId !== null}
<PaneResizer
class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850"
>
{#key id}
<Messages
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
<EllipsisVertical className="size-4 invisible group-hover:visible" />
</div>
</PaneResizer>
<Pane defaultSize={50} minSize={30} class="h-full w-full">
<div class="h-full w-full shadow-xl">
<Thread
{threadId}
{channel}
{messages}
{top}
onLoad={async () => {
const newMessages = await getChannelMessages(localStorage.token, id, messages.length);
messages = [...messages, ...newMessages];
if (newMessages.length < 50) {
top = true;
return;
}
onClose={() => {
threadId = null;
}}
/>
{/key}
</div>
</div>
</Pane>
{/if}
</div>
<div class=" pb-[1rem]">
<MessageInput onSubmit={submitHandler} {scrollToBottom} {scrollEnd} />
</div>
</PaneGroup>
</div>
+86 -107
View File
@@ -6,7 +6,7 @@
const i18n = getContext('i18n');
import { config, mobile, settings } from '$lib/stores';
import { config, mobile, settings, socket } from '$lib/stores';
import { blobToFile, compressImage } from '$lib/utils';
import Tooltip from '../common/Tooltip.svelte';
@@ -23,6 +23,8 @@
export let placeholder = $i18n.t('Send a Message');
export let transparentBackground = false;
export let id = null;
let draggedOver = false;
let recording = false;
@@ -32,9 +34,12 @@
let filesInputElement;
let inputFiles;
export let typingUsers = [];
export let onSubmit: Function;
export let onChange: Function;
export let scrollEnd = true;
export let scrollToBottom: Function;
export let scrollToBottom: Function = () => {};
const screenCaptureHandler = async () => {
try {
@@ -153,7 +158,7 @@
// Check if the file is an audio file and transcribe/convert it to text file
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
@@ -238,7 +243,7 @@
};
const submitHandler = async () => {
if (content === '') {
if (content === '' && files.length === 0) {
return;
}
@@ -254,13 +259,17 @@
await tick();
const chatInputElement = document.getElementById('chat-input');
const chatInputElement = document.getElementById(`chat-input-${id}`);
chatInputElement?.focus();
};
$: if (content) {
onChange();
}
onMount(async () => {
window.setTimeout(() => {
const chatInput = document.getElementById('chat-input');
const chatInput = document.getElementById(`chat-input-${id}`);
chatInput?.focus();
}, 0);
@@ -290,37 +299,6 @@
<FilesOverlay show={draggedOver} />
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 max-w-6xl w-full">
<div class="relative">
{#if scrollEnd === false}
<div class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none">
<button
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
on:click={() => {
scrollEnd = true;
scrollToBottom();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
</div>
</div>
<input
bind:this={filesInputElement}
bind:files={inputFiles}
@@ -337,12 +315,58 @@
filesInputElement.value = '';
}}
/>
<div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
<div class="bg-transparent">
<div
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
>
<div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 w-full">
<div class="relative">
{#if scrollEnd === false}
<div
class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
>
<button
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
on:click={() => {
scrollEnd = true;
scrollToBottom();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
<div class="relative">
<div class=" -mt-5">
{#if typingUsers.length > 0}
<div class=" text-xs px-4 mb-1">
<span class=" font-normal text-black dark:text-white">
{typingUsers.map((user) => user.name).join(', ')}
</span>
{$i18n.t('is typing...')}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="">
{#if recording}
<VoiceRecording
@@ -351,7 +375,7 @@
recording = false;
await tick();
document.getElementById('chat-input')?.focus();
document.getElementById(`chat-input-${id}`)?.focus();
}}
on:confirm={async (e) => {
const { text, filename } = e.detail;
@@ -359,7 +383,7 @@
recording = false;
await tick();
document.getElementById('chat-input')?.focus();
document.getElementById(`chat-input-${id}`)?.focus();
}}
/>
{:else}
@@ -370,7 +394,7 @@
}}
>
<div
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-50 dark:bg-gray-400/5 dark:text-gray-100"
class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'LTR'}
>
{#if files.length > 0}
@@ -456,61 +480,21 @@
</InputMenu>
</div>
{#if $settings?.richTextInput ?? true}
<div
class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
>
<RichTextInput
bind:value={content}
id="chat-input"
messageInput={true}
shiftEnter={!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)}
{placeholder}
largeTextAsFile={$settings?.largeTextAsFile ?? false}
on:keydown={async (e) => {
e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
if (
!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
) {
// Prevent Enter key from creating a new line
// Uses keyCode '13' for Enter key for chinese/japanese keyboards
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
}
// Submit the content when Enter key is pressed
if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
submitHandler();
}
}
if (e.key === 'Escape') {
console.log('Escape');
}
}}
on:paste={async (e) => {
e = e.detail.event;
console.log(e);
}}
/>
</div>
{:else}
<textarea
id="chat-input"
class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
{placeholder}
<div
class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
>
<RichTextInput
bind:value={content}
id={`chat-input-${id}`}
messageInput={true}
shiftEnter={!$mobile ||
!(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)}
{placeholder}
largeTextAsFile={$settings?.largeTextAsFile ?? false}
on:keydown={async (e) => {
e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
@@ -538,17 +522,12 @@
console.log('Escape');
}
}}
rows="1"
on:input={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
}}
on:focus={async (e) => {
e.target.style.height = '';
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
on:paste={async (e) => {
e = e.detail.event;
console.log(e);
}}
/>
{/if}
</div>
<div class="self-end mb-1.5 flex space-x-1 mr-1">
{#if content === ''}
@@ -602,11 +581,11 @@
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{content !== ''
class="{content !== '' || files.length !== 0
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={content === ''}
disabled={content === '' && files.length === 0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
+84 -7
View File
@@ -11,20 +11,23 @@
dayjs.extend(isYesterday);
import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
import { settings } from '$lib/stores';
import { settings, user } from '$lib/stores';
import Message from './Messages/Message.svelte';
import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.svelte';
import { deleteMessage, updateMessage } from '$lib/apis/channels';
import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels';
const i18n = getContext('i18n');
export let id = null;
export let channel = null;
export let messages = [];
export let top = false;
export let thread = false;
export let onLoad: Function = () => {};
export let onThread: Function = () => {};
let messagesLoading = false;
@@ -59,14 +62,14 @@
<div class=" ">Loading...</div>
</div>
</Loader>
{:else}
{:else if !thread}
<div
class="px-5
{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} mx-auto"
>
{#if channel}
<div class="flex flex-col gap-1.5 py-5">
<div class="flex flex-col gap-1.5 pb-5 pt-10">
<div class="text-2xl font-medium capitalize">{channel.name}</div>
<div class=" text-gray-500">
@@ -88,27 +91,101 @@
</div>
{/if}
{#each messageList as message, messageIdx (message.id)}
{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
<Message
{message}
{thread}
showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id}
onDelete={() => {
messages = messages.filter((m) => m.id !== message.id);
const res = deleteMessage(localStorage.token, message.channel_id, message.id).catch(
(error) => {
toast.error(error);
toast.error(`${error}`);
return null;
}
);
}}
onEdit={(content) => {
messages = messages.map((m) => {
if (m.id === message.id) {
m.content = content;
}
return m;
});
const res = updateMessage(localStorage.token, message.channel_id, message.id, {
content: content
}).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
}}
onThread={(id) => {
onThread(id);
}}
onReaction={(name) => {
if (
(message?.reactions ?? [])
.find((reaction) => reaction.name === name)
?.user_ids?.includes($user.id) ??
false
) {
messages = messages.map((m) => {
if (m.id === message.id) {
const reaction = m.reactions.find((reaction) => reaction.name === name);
if (reaction) {
reaction.user_ids = reaction.user_ids.filter((id) => id !== $user.id);
reaction.count = reaction.user_ids.length;
if (reaction.count === 0) {
m.reactions = m.reactions.filter((r) => r.name !== name);
}
}
}
return m;
});
const res = removeReaction(
localStorage.token,
message.channel_id,
message.id,
name
).catch((error) => {
toast.error(`${error}`);
return null;
});
} else {
messages = messages.map((m) => {
if (m.id === message.id) {
if (m.reactions) {
const reaction = m.reactions.find((reaction) => reaction.name === name);
if (reaction) {
reaction.user_ids.push($user.id);
reaction.count = reaction.user_ids.length;
} else {
m.reactions.push({
name: name,
user_ids: [$user.id],
count: 1
});
}
}
}
return m;
});
const res = addReaction(localStorage.token, message.channel_id, message.id, name).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
}
}}
/>
{/each}
@@ -8,10 +8,10 @@
dayjs.extend(isToday);
dayjs.extend(isYesterday);
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
import { settings, user } from '$lib/stores';
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
@@ -25,29 +25,27 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import Image from '$lib/components/common/Image.svelte';
import FileItem from '$lib/components/common/FileItem.svelte';
import ProfilePreview from './Message/ProfilePreview.svelte';
import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubbleOvalEllipsis.svelte';
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import ReactionPicker from './Message/ReactionPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils';
export let message;
export let showUserProfile = true;
export let thread = false;
export let onDelete: Function = () => {};
export let onEdit: Function = () => {};
export let onThread: Function = () => {};
export let onReaction: Function = () => {};
let showButtons = false;
let edit = false;
let editedContent = null;
let showDeleteConfirmDialog = false;
const formatDate = (inputDate) => {
const date = dayjs(inputDate);
const now = dayjs();
if (date.isToday()) {
return `Today at ${date.format('HH:mm')}`;
} else if (date.isYesterday()) {
return `Yesterday at ${date.format('HH:mm')}`;
} else {
return `${date.format('DD/MM/YYYY')} at ${date.format('HH:mm')}`;
}
};
</script>
<ConfirmDialog
@@ -65,29 +63,69 @@
? 'pt-1.5 pb-0.5'
: ''} w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-5xl'} mx-auto group hover:bg-gray-500/5 transition relative"
: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
>
{#if (message.user_id === $user.id || $user.role === 'admin') && !edit}
<div class=" absolute invisible group-hover:visible right-1 -top-2 z-30">
{#if !edit}
<div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
>
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
edit = true;
editedContent = message.content;
<ReactionPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<Pencil />
</button>
<Tooltip content={$i18n.t('Add Reaction')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
}}
>
<FaceSmile />
</button>
</Tooltip>
</ReactionPicker>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
{#if !thread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onThread(message.id);
}}
>
<ChatBubbleOvalEllipsis />
</button>
</Tooltip>
{/if}
{#if message.user_id === $user.id || $user.role === 'admin'}
<Tooltip content={$i18n.t('Edit')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
edit = true;
editedContent = message.content;
}}
>
<Pencil />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{/if}
</div>
</div>
{/if}
@@ -101,11 +139,13 @@
class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
>
{#if showUserProfile}
<ProfileImage
src={message.user?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8 translate-y-1 ml-0.5'}
/>
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ??
($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8 translate-y-1 ml-0.5'}
/>
</ProfilePreview>
{:else}
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
@@ -126,7 +166,7 @@
<div class="flex-auto w-0 pl-1">
{#if showUserProfile}
<Name>
<div class=" self-end text-base font-medium">
<div class=" self-end text-base shrink-0 font-medium truncate">
{message?.user?.name}
</div>
@@ -137,7 +177,7 @@
<Tooltip
content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
>
{formatDate(message.created_at / 1000000)}
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
</Tooltip>
</div>
{/if}
@@ -219,6 +259,85 @@
>(edited)</span
>{/if}
</div>
{#if (message?.reactions ?? []).length > 0}
<div>
<div class="flex items-center flex-wrap gap-y-1.5 gap-1 mt-1 mb-2">
{#each message.reactions as reaction}
<Tooltip content={`:${reaction.name}:`}>
<button
class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes(
$user.id
)
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
on:click={() => {
onReaction(reaction.name);
}}
>
{#if $shortCodesToEmojis[reaction.name]}
<img
src="/assets/emojis/{$shortCodesToEmojis[
reaction.name
].toLowerCase()}.svg"
alt={reaction.name}
class=" size-4"
loading="lazy"
/>
{:else}
<div>
{reaction.name}
</div>
{/if}
{#if reaction.user_ids.length > 0}
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
{reaction.user_ids?.length}
</div>
{/if}
</button>
</Tooltip>
{/each}
<ReactionPicker
onSubmit={(name) => {
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<div
class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
>
<FaceSmile />
</div>
</Tooltip>
</ReactionPicker>
</div>
</div>
{/if}
{#if !thread && message.reply_count > 0}
<div class="flex items-center gap-1.5 -mt-0.5 mb-1.5">
<button
class="flex items-center text-xs py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={() => {
onThread(message.id);
}}
>
<span class="font-medium mr-1">
{$i18n.t('{{COUNT}} Replies', { COUNT: message.reply_count })}</span
><span>
{' - '}{$i18n.t('Last reply')}
{dayjs.unix(message.latest_reply_at / 1000000000).fromNow()}</span
>
<span class="ml-1">
<ChevronRight className="size-2.5" strokeWidth="3" />
</span>
<!-- {$i18n.t('View Replies')} -->
</button>
</div>
{/if}
{/if}
</div>
</div>
@@ -0,0 +1,85 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { createEventDispatcher } from 'svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { WEBUI_BASE_URL } from '$lib/constants';
import { activeUserIds } from '$lib/stores';
export let side = 'right';
export let align = 'top';
export let user = null;
let show = false;
const dispatch = createEventDispatcher();
</script>
<DropdownMenu.Root
bind:open={show}
closeFocus={false}
onOpenChange={(state) => {
dispatch('change', state);
}}
typeahead={false}
>
<DropdownMenu.Trigger>
<slot />
</DropdownMenu.Trigger>
<slot name="content">
<DropdownMenu.Content
class="max-w-full w-[240px] rounded-lg z-[9999] bg-white dark:bg-black dark:text-white shadow-lg"
sideOffset={8}
{side}
{align}
transition={flyAndScale}
>
{#if user}
<div class=" flex flex-col gap-2 w-full rounded-lg">
<div class="py-8 relative bg-gray-900 rounded-t-lg">
<img
crossorigin="anonymous"
src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
class=" absolute -bottom-5 left-3 size-12 ml-0.5 object-cover rounded-full -translate-y-[1px]"
alt="profile"
/>
</div>
<div class=" flex flex-col pt-4 pb-2.5 px-4">
<div class=" -mb-1">
<span class="font-medium text-sm line-clamp-1"> {user.name} </span>
</div>
<div class=" flex items-center gap-2">
{#if $activeUserIds.includes(user.id)}
<div>
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<div class=" -translate-y-[1px]">
<span class="text-xs"> Active </span>
</div>
{:else}
<div>
<span class="relative flex size-2">
<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
</span>
</div>
<div class=" -translate-y-[1px]">
<span class="text-xs"> Away </span>
</div>
{/if}
</div>
</div>
</div>
{/if}
</DropdownMenu.Content>
</slot>
</DropdownMenu.Root>
@@ -0,0 +1,166 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import emojiGroups from '$lib/emoji-groups.json';
import emojiShortCodes from '$lib/emoji-shortcodes.json';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import VirtualList from '@sveltejs/svelte-virtual-list';
export let onClose = () => {};
export let onSubmit = (name) => {};
export let side = 'top';
export let align = 'start';
export let user = null;
let show = false;
let emojis = emojiShortCodes;
let search = '';
let flattenedEmojis = [];
let emojiRows = [];
// Reactive statement to filter the emojis based on search query
$: {
if (search) {
emojis = Object.keys(emojiShortCodes).reduce((acc, key) => {
if (key.includes(search)) {
acc[key] = emojiShortCodes[key];
} else {
if (Array.isArray(emojiShortCodes[key])) {
const filtered = emojiShortCodes[key].filter((emoji) => emoji.includes(search));
if (filtered.length) {
acc[key] = filtered;
}
} else {
if (emojiShortCodes[key].includes(search)) {
acc[key] = emojiShortCodes[key];
}
}
}
return acc;
}, {});
} else {
emojis = emojiShortCodes;
}
}
// Flatten emoji groups and group them into rows of 8 for virtual scrolling
$: {
flattenedEmojis = [];
Object.keys(emojiGroups).forEach((group) => {
const groupEmojis = emojiGroups[group].filter((emoji) => emojis[emoji]);
if (groupEmojis.length > 0) {
flattenedEmojis.push({ type: 'group', label: group });
flattenedEmojis.push(
...groupEmojis.map((emoji) => ({
type: 'emoji',
name: emoji,
shortCodes:
typeof emojiShortCodes[emoji] === 'string'
? [emojiShortCodes[emoji]]
: emojiShortCodes[emoji]
}))
);
}
});
// Group emojis into rows of 8
emojiRows = [];
let currentRow = [];
flattenedEmojis.forEach((item) => {
if (item.type === 'emoji') {
currentRow.push(item);
if (currentRow.length === 8) {
emojiRows.push(currentRow);
currentRow = [];
}
} else if (item.type === 'group') {
if (currentRow.length > 0) {
emojiRows.push(currentRow); // Push the remaining row
currentRow = [];
}
emojiRows.push([item]); // Add the group label as a separate row
}
});
if (currentRow.length > 0) {
emojiRows.push(currentRow); // Push the final row
}
}
const ROW_HEIGHT = 48; // Approximate height for a row with multiple emojis
// Handle emoji selection
function selectEmoji(emoji) {
const selectedCode = emoji.shortCodes[0];
onSubmit(selectedCode);
show = false;
}
</script>
<DropdownMenu.Root
bind:open={show}
closeFocus={false}
onOpenChange={(state) => {
if (!state) {
search = '';
onClose();
}
}}
typeahead={false}
>
<DropdownMenu.Trigger>
<slot />
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="max-w-full w-80 bg-gray-50 dark:bg-gray-850 rounded-lg z-[9999] shadow-lg dark:text-white"
sideOffset={8}
{side}
{align}
transition={flyAndScale}
>
<div class="mb-1 px-3 pt-2 pb-2">
<input
type="text"
class="w-full text-sm bg-transparent outline-none"
placeholder="Search all emojis"
bind:value={search}
/>
</div>
<!-- Virtualized Emoji List -->
<div class="w-full flex justify-start h-96 overflow-y-auto px-3 pb-3 text-sm">
{#if emojiRows.length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400">No results</div>
{:else}
<div class="w-full flex ml-0.5">
<VirtualList rowHeight={ROW_HEIGHT} items={emojiRows} height={384} let:item>
<div class="w-full">
{#if item.length === 1 && item[0].type === 'group'}
<!-- Render group header -->
<div class="text-xs font-medium mb-2 text-gray-500 dark:text-gray-400">
{item[0].label}
</div>
{:else}
<!-- Render emojis in a row -->
<div class="flex items-center gap-1.5 w-full">
{#each item as emojiItem}
<Tooltip
content={emojiItem.shortCodes.map((code) => `:${code}:`).join(', ')}
placement="top"
>
<button
class="p-1.5 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition"
on:click={() => selectEmoji(emojiItem)}
>
<img
src="/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
alt={emojiItem.name}
class="size-5"
loading="lazy"
/>
</button>
</Tooltip>
{/each}
</div>
{/if}
</div>
</VirtualList>
</div>
{/if}
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
+2 -2
View File
@@ -16,7 +16,7 @@
export let channel;
</script>
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
<nav class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center drag-region">
<div
class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
></div>
@@ -83,4 +83,4 @@
</div>
</div>
</div>
</div>
</nav>
+204
View File
@@ -0,0 +1,204 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { socket, user } from '$lib/stores';
import { getChannelThreadMessages, sendMessage } from '$lib/apis/channels';
import XMark from '$lib/components/icons/XMark.svelte';
import MessageInput from './MessageInput.svelte';
import Messages from './Messages.svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { toast } from 'svelte-sonner';
export let threadId = null;
export let channel = null;
export let onClose = () => {};
let messages = null;
let top = false;
let typingUsers = [];
let typingUsersTimeout = {};
let messagesContainerElement = null;
$: if (threadId) {
initHandler();
}
const scrollToBottom = () => {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
};
const initHandler = async () => {
messages = null;
top = false;
typingUsers = [];
typingUsersTimeout = {};
if (channel) {
messages = await getChannelThreadMessages(localStorage.token, channel.id, threadId);
if (messages.length < 50) {
top = true;
}
await tick();
scrollToBottom();
} else {
goto('/');
}
};
const channelEventHandler = async (event) => {
console.log(event);
if (event.channel_id === channel.id) {
const type = event?.data?.type ?? null;
const data = event?.data?.data ?? null;
if (type === 'message') {
if ((data?.parent_id ?? null) === threadId) {
if (messages) {
messages = [data, ...messages];
if (typingUsers.find((user) => user.id === event.user.id)) {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}
}
}
} else if (type === 'message:update') {
if (messages) {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
}
} else if (type === 'message:delete') {
if (messages) {
messages = messages.filter((message) => message.id !== data.id);
}
} else if (type.includes('message:reaction')) {
if (messages) {
const idx = messages.findIndex((message) => message.id === data.id);
if (idx !== -1) {
messages[idx] = data;
}
}
} else if (type === 'typing' && event.message_id === threadId) {
if (event.user.id === $user.id) {
return;
}
typingUsers = data.typing
? [
...typingUsers,
...(typingUsers.find((user) => user.id === event.user.id)
? []
: [
{
id: event.user.id,
name: event.user.name
}
])
]
: typingUsers.filter((user) => user.id !== event.user.id);
if (typingUsersTimeout[event.user.id]) {
clearTimeout(typingUsersTimeout[event.user.id]);
}
typingUsersTimeout[event.user.id] = setTimeout(() => {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}, 5000);
}
}
};
const submitHandler = async ({ content, data }) => {
if (!content) {
return;
}
const res = await sendMessage(localStorage.token, channel.id, {
parent_id: threadId,
content: content,
data: data
}).catch((error) => {
toast.error(`${error}`);
return null;
});
};
const onChange = async () => {
$socket?.emit('channel-events', {
channel_id: channel.id,
message_id: threadId,
data: {
type: 'typing',
data: {
typing: true
}
}
});
};
onMount(() => {
$socket?.on('channel-events', channelEventHandler);
});
onDestroy(() => {
$socket?.off('channel-events', channelEventHandler);
});
</script>
{#if channel}
<div class="flex flex-col w-full h-full bg-gray-50 dark:bg-gray-850">
<div class="flex items-center justify-between px-3.5 pt-3">
<div class=" font-medium text-lg">Thread</div>
<div>
<button
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-2"
on:click={() => {
onClose();
}}
>
<XMark />
</button>
</div>
</div>
<div class=" max-h-full w-full overflow-y-auto pt-3" bind:this={messagesContainerElement}>
<Messages
id={threadId}
{channel}
{messages}
{top}
thread={true}
onLoad={async () => {
const newMessages = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
messages.length
);
messages = [...messages, ...newMessages];
if (newMessages.length < 50) {
top = true;
return;
}
}}
/>
<div class=" pb-[1rem]">
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
</div>
</div>
</div>
{/if}
+132 -64
View File
@@ -44,7 +44,8 @@
extractSentencesForAudio,
promptTemplate,
splitStream,
sleep
sleep,
removeDetailsWithReasoning
} from '$lib/utils';
import { generateChatCompletion } from '$lib/apis/ollama';
@@ -111,6 +112,7 @@
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
let selectedToolIds = [];
let imageGenerationEnabled = false;
let webSearchEnabled = false;
let chat = null;
@@ -137,6 +139,7 @@
files = [];
selectedToolIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
loaded = false;
@@ -152,6 +155,7 @@
files = input.files;
selectedToolIds = input.selectedToolIds;
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
} catch (e) {}
}
@@ -318,6 +322,19 @@
eventConfirmationMessage = data.message;
eventConfirmationInputPlaceholder = data.placeholder;
eventConfirmationInputValue = data?.value ?? '';
} else if (type === 'notification') {
const toastType = data?.type ?? 'info';
const toastContent = data?.content ?? '';
if (toastType === 'success') {
toast.success(toastContent);
} else if (toastType === 'error') {
toast.error(toastContent);
} else if (toastType === 'warning') {
toast.warning(toastContent);
} else {
toast.info(toastContent);
}
} else {
console.log('Unknown message type', data);
}
@@ -390,11 +407,13 @@
files = input.files;
selectedToolIds = input.selectedToolIds;
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
} catch (e) {
prompt = '';
files = [];
selectedToolIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
}
}
@@ -427,7 +446,7 @@
onDestroy(() => {
chatIdUnsubscriber?.();
window.removeEventListener('message', onMessageHandler);
// $socket?.off('chat-events');
$socket?.off('chat-events', chatEventHandler);
});
// File upload functions
@@ -696,6 +715,9 @@
if ($page.url.searchParams.get('web-search') === 'true') {
webSearchEnabled = true;
}
if ($page.url.searchParams.get('image-generation') === 'true') {
imageGenerationEnabled = true;
}
if ($page.url.searchParams.get('tools')) {
selectedToolIds = $page.url.searchParams
@@ -830,22 +852,25 @@
session_id: $socket?.id,
id: responseMessageId
}).catch((error) => {
toast.error(error);
toast.error(`${error}`);
messages.at(-1).error = { content: error };
return null;
});
if (res !== null) {
if (res !== null && res.messages) {
// Update chat history with the new messages
for (const message of res.messages) {
history.messages[message.id] = {
...history.messages[message.id],
...(history.messages[message.id].content !== message.content
? { originalContent: history.messages[message.id].content }
: {}),
...message
};
if (message?.id) {
// Add null check for message and message.id
history.messages[message.id] = {
...history.messages[message.id],
...(history.messages[message.id].content !== message.content
? { originalContent: history.messages[message.id].content }
: {}),
...message
};
}
}
}
@@ -885,12 +910,12 @@
session_id: $socket?.id,
id: responseMessageId
}).catch((error) => {
toast.error(error);
toast.error(`${error}`);
messages.at(-1).error = { content: error };
return null;
});
if (res !== null) {
if (res !== null && res.messages) {
// Update chat history with the new messages
for (const message of res.messages) {
history.messages[message.id] = {
@@ -1053,7 +1078,7 @@
};
const chatCompletionEventHandler = async (data, message, chatId) => {
const { id, done, choices, sources, selected_model_id, error, usage } = data;
const { id, done, choices, content, sources, selected_model_id, error, usage } = data;
if (error) {
await handleOpenAIError(error, message);
@@ -1064,41 +1089,79 @@
}
if (choices) {
let value = choices[0]?.delta?.content ?? '';
if (message.content == '' && value == '\n') {
console.log('Empty response');
if (choices[0]?.message?.content) {
// Non-stream response
message.content += choices[0]?.message?.content;
} else {
message.content += value;
// Stream response
let value = choices[0]?.delta?.content ?? '';
if (message.content == '' && value == '\n') {
console.log('Empty response');
} else {
message.content += value;
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
// Emit chat event for TTS
const messageContentParts = getMessageContentParts(
message.content,
$config?.audio?.tts?.split_on ?? 'punctuation'
);
messageContentParts.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
messageContentParts.length > 0 &&
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
) {
message.lastSentence = messageContentParts[messageContentParts.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: messageContentParts[messageContentParts.length - 1]
}
})
// Emit chat event for TTS
const messageContentParts = getMessageContentParts(
message.content,
$config?.audio?.tts?.split_on ?? 'punctuation'
);
messageContentParts.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
messageContentParts.length > 0 &&
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
) {
message.lastSentence = messageContentParts[messageContentParts.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: messageContentParts[messageContentParts.length - 1]
}
})
);
}
}
}
}
if (content) {
// REALTIME_CHAT_SAVE is disabled
message.content = content;
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
// Emit chat event for TTS
const messageContentParts = getMessageContentParts(
message.content,
$config?.audio?.tts?.split_on ?? 'punctuation'
);
messageContentParts.pop();
// dispatch only last sentence and make sure it hasn't been dispatched before
if (
messageContentParts.length > 0 &&
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
) {
message.lastSentence = messageContentParts[messageContentParts.length - 1];
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: messageContentParts[messageContentParts.length - 1]
}
})
);
}
}
if (selected_model_id) {
message.selectedModelId = selected_model_id;
message.arena = true;
@@ -1108,16 +1171,11 @@
message.usage = usage;
}
history.messages[message.id] = message;
if (done) {
message.done = true;
if ($settings.notificationEnabled && !document.hasFocus()) {
new Notification(`${message.model}`, {
body: message.content,
icon: `${WEBUI_BASE_URL}/static/favicon.png`
});
}
if ($settings.responseAutoCopy) {
copyToClipboard(message.content);
}
@@ -1152,8 +1210,6 @@
await chatCompletedHandler(chatId, message.model, message.id, createMessagesList(message.id));
}
history.messages[message.id] = message;
console.log(data);
if (autoScroll) {
scrollToBottom();
@@ -1317,7 +1373,8 @@
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null) {
if (parentId !== null && history.messages[parentId]) {
// Add null check before accessing childrenIds
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
@@ -1361,7 +1418,7 @@
if ($settings?.memory ?? false) {
if (userContext === null) {
const res = await queryMemory(localStorage.token, prompt).catch((error) => {
toast.error(error);
toast.error(`${error}`);
return null;
});
if (res) {
@@ -1447,7 +1504,10 @@
}`
}
: undefined,
...createMessagesList(responseMessageId)
...createMessagesList(responseMessageId).map((message) => ({
...message,
content: removeDetailsWithReasoning(message.content)
}))
]
.filter((message) => message?.content?.trim())
.map((message, idx, arr) => ({
@@ -1495,9 +1555,10 @@
: undefined
},
files: files.length > 0 ? files : undefined,
files: (files?.length ?? 0) > 0 ? files : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
features: {
image_generation: imageGenerationEnabled,
web_search: webSearchEnabled
},
@@ -1505,7 +1566,12 @@
chat_id: $chatId,
id: responseMessageId,
...(!$temporaryChatEnabled && messages.length == 1 && selectedModels[0] === model.id
...(!$temporaryChatEnabled &&
(messages.length == 1 ||
(messages.length == 2 &&
messages.at(0)?.role === 'system' &&
messages.at(1)?.role === 'user')) &&
selectedModels[0] === model.id
? {
background_tasks: {
title_generation: $settings?.title?.auto ?? true,
@@ -1789,13 +1855,13 @@
}}
/>
{#if !chatIdProp || (loaded && chatIdProp)}
<div
class="h-screen max-h-[100dvh] {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
: ''} w-full max-w-full flex flex-col"
id="chat-container"
>
<div
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? ' md:max-w-[calc(100%-260px)]'
: ' '} w-full max-w-full flex flex-col"
id="chat-container"
>
{#if !chatIdProp || (loaded && chatIdProp)}
{#if $settings?.backgroundImageUrl ?? null}
<div
class="absolute {$showSidebar
@@ -1895,6 +1961,7 @@
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:imageGenerationEnabled
bind:webSearchEnabled
bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false}
@@ -1945,6 +2012,7 @@
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:imageGenerationEnabled
bind:webSearchEnabled
bind:atSelectedModel
transparentBackground={$settings?.backgroundImageUrl ?? false}
@@ -1997,5 +2065,5 @@
{eventTarget}
/>
</PaneGroup>
</div>
{/if}
{/if}
</div>
+1 -1
View File
@@ -146,7 +146,7 @@
>
<div
class=" {$showCallOverlay || $showOverview || $showArtifacts
? ' h-screen w-screen'
? ' h-screen w-full'
: 'px-6 py-4'} h-full"
>
{#if $showCallOverlay}

Some files were not shown because too many files have changed in this diff Show More